("buildIslRuntimeLocal") {
+ group = "build"
+ description = "Build isl-cmd fat JAR and copy to plugin/lib for extension use"
+ dependsOn(":isl-cmd:shadowJar")
+ from(project(":isl-cmd").tasks.named("shadowJar").map { (it as org.gradle.api.tasks.bundling.Jar).archiveFile })
+ into(file("plugin/lib"))
+ rename { "isl-cmd-all.jar" }
+}
+
+tasks.register("publishToMavenCentral") {
+ group = "publishing"
+ description = "Publish all modules to Maven Central"
+ publishModules.forEach { name ->
+ project(name).tasks.findByName("publishToMavenCentral")?.let { dependsOn(it) }
+ }
+ doFirst {
+ if (taskDependencies.getDependencies(this).isEmpty()) {
+ throw GradleException(
+ "Maven Central credentials required: set MAVEN_CENTRAL_USERNAME and MAVEN_CENTRAL_PASSWORD (or OSSRH_*), or mavenCentralUsername/Password in root gradle.properties. For local only: gradlew publishToMavenLocal"
+ )
}
}
}
diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml
index 88cbf12..7703e65 100644
--- a/docs/_data/navigation.yml
+++ b/docs/_data/navigation.yml
@@ -84,12 +84,6 @@ docs:
url: /dev/hosting
- title: "Performance Benchmarks"
url: /dev/benchmark-report
- - title: "Release Process"
- url: /dev/release
- - title: "Contributing"
- url: /dev/contributing
- - title: "Playground Integration"
- url: /dev/playground-integration
- title: More
children:
@@ -99,6 +93,10 @@ docs:
url: /changelog
- title: "Roadmap"
url: /roadmap
+ - title: "Contributing"
+ url: /dev/contributing
+ - title: "Release Process"
+ url: /dev/release
- title: "Support"
url: /support
diff --git a/docs/assets/css/main.scss b/docs/assets/css/main.scss
index b0c0d08..5379cce 100644
--- a/docs/assets/css/main.scss
+++ b/docs/assets/css/main.scss
@@ -95,6 +95,14 @@ pre[class*="language-"] {
}
}
+/* Override Prism colors - change from #db4c69 to #9cdcfe */
+// code.highlighter-rouge.languange-plaintext,
+body {
+ :not(pre) > code[class*="language-"] {
+ color: #9cdcfe !important;
+ }
+}
+
/* Override Prism colors - change from #db4c69 to #9cdcfe */
code.highlighter-rouge.languange-plaintext,
body :not(pre) > code[class*="language-"] {
diff --git a/docs/assets/js/playground-auto-buttons.js b/docs/assets/js/playground-auto-buttons.js
index 0e52a95..499f263 100644
--- a/docs/assets/js/playground-auto-buttons.js
+++ b/docs/assets/js/playground-auto-buttons.js
@@ -21,6 +21,19 @@
}
}
+ /**
+ * Checks if a code block is explicitly marked as JSON
+ */
+ function hasJsonClass(preElement) {
+ // Check pre element classes
+ const preClasses = preElement.className || '';
+ if (preClasses.includes('language-json') || preClasses.includes('highlighter-json')) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Checks if a code block is explicitly marked as ISL
*/
@@ -103,26 +116,13 @@
iterations++;
// Check if this is a with JSON code
- if (currentElement.tagName === 'PRE') {
+ if (currentElement.tagName === 'PRE' && hasJsonClass(currentElement)) {
const codeElement = currentElement.querySelector('code');
if (codeElement) {
const text = codeElement.textContent.trim();
// Simple check if it looks like JSON
if (text.startsWith('{') || text.startsWith('[')) {
- // Check if there's an "Input JSON" label before this
- let labelElement = currentElement.previousElementSibling;
- let labelChecks = 0;
-
- while (labelElement && labelChecks < 3) {
- labelChecks++;
- const labelText = labelElement.textContent || '';
-
- if (/\w*input\w*/i.test(labelText)) {
- return text;
- }
-
- labelElement = labelElement.previousElementSibling;
- }
+ return text;
}
}
}
diff --git a/docs/changelog.md b/docs/changelog.md
index d892c68..6c45667 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,7 +3,7 @@ title: Changelog
nav_order: 100
---
-## [Unreleased] - Performance Benchmarks
+## [1.1.0] - First public release
### Performance Metrics
@@ -85,5 +85,4 @@ Added comprehensive JMH (Java Microbenchmark Harness) benchmarking framework to
---
-**2.4.20** - First public release
diff --git a/docs/cli.md b/docs/cli.md
index ee0c6a3..6555d9a 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -97,7 +97,7 @@ For development, you can run directly with Gradle:
## Basic Usage
-The ISL CLI has three main commands:
+The ISL CLI has four main commands:
### 1. Transform Command
@@ -138,7 +138,38 @@ isl validate script.isl
# If invalid, shows error details and exit code 1
```
-### 3. Info Command
+### 3. Test Command
+
+Run ISL unit tests. Discovers `.isl` files containing `@test` or `@setup` annotations and executes them.
+
+```bash
+# Run tests in current directory (default: **/*.isl)
+isl test
+
+# Run tests in a specific path (directory, file, or glob)
+isl test tests/
+isl test tests/sample.isl
+
+# Custom glob pattern
+isl test tests/ --glob "**/*.test.isl"
+
+# Write results to JSON file
+isl test -o results.json
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `path` | Directory, file, or glob to search (default: current directory) |
+| `--glob PATTERN` | Glob to filter files when path is a directory (default: `**/*.isl`) |
+| `-o, --output FILE` | Write test results to JSON file |
+
+Exit code: 0 if all tests pass, 1 if any fail.
+
+See [Unit Testing](../ext/unit-testing/index.md) for writing tests, assertions, and loading fixtures.
+
+### 4. Info Command
Display version and system information:
@@ -176,6 +207,27 @@ isl info
debug=true isl transform script.isl -i input.json
```
+### Logging from ISL Scripts
+
+When running transforms or tests from the CLI, you can log messages from your ISL scripts:
+
+```isl
+@.Log.Info("Processing started")
+@.Log.Info("Item count:", $count)
+@.Log.Warn("Unexpected value:", $value)
+@.Log.Error("Failed:", $error)
+@.Log.Debug("Debug info") // Only outputs when -Ddebug=true
+```
+
+| Function | Output | When |
+|----------|--------|------|
+| `@.Log.Debug(...)` | stdout | Only when `-Ddebug=true` |
+| `@.Log.Info(...)` | stdout | Always |
+| `@.Log.Warn(...)` | stderr | Always |
+| `@.Log.Error(...)` | stderr | Always |
+
+All functions accept multiple arguments (strings, variables, expressions); they are joined with spaces. JSON objects are pretty-printed.
+
## Working with Input Data
### Using Input Files
diff --git a/docs/examples/index.md b/docs/examples/index.md
index 4d45661..223088e 100644
--- a/docs/examples/index.md
+++ b/docs/examples/index.md
@@ -6,8 +6,6 @@ description: "Common ISL transformation patterns for JSON data manipulation incl
excerpt: "Common ISL transformation patterns for JSON data manipulation including field mapping, array transformations, nested objects, and more."
---
-# ISL Transformation Examples
-
This guide demonstrates common JSON transformation patterns using ISL. Each example shows how to handle typical data transformation scenarios.
## Table of Contents
@@ -29,7 +27,7 @@ This guide demonstrates common JSON transformation patterns using ISL. Each exam
**Use Case:** Copy and rename fields from input to output
-**Documentation:** [Variables](/isl/language/variables/) | [Objects](/isl/language/objects/)
+**Documentation:** [Variables](/isl/language/variables/), [Objects](/isl/language/objects/)
**Input:**
```json
@@ -64,7 +62,7 @@ This guide demonstrates common JSON transformation patterns using ISL. Each exam
**Use Case:** Rename multiple fields and reorganize structure
-**Documentation:** [Objects](/isl/language/objects/) | [Variables](/isl/language/variables/)
+**Documentation:** [Objects](/isl/language/objects/), [Variables](/isl/language/variables/)
**Input:**
```json
@@ -106,7 +104,7 @@ This guide demonstrates common JSON transformation patterns using ISL. Each exam
**Use Case:** Transform each item in an array
-**Documentation:** [Loops](/isl/language/loops/) | [Built-in Modifiers](/isl/language/modifiers/)
+**Documentation:** [Loops](/isl/language/loops/), [Built-in Modifiers](/isl/language/modifiers/)
**Input:**
```json
@@ -159,7 +157,7 @@ or using `|map ( )`
**Use Case:** Flatten nested structure into flat object
-**Documentation:** [Objects](/isl/language/objects/) | [Variables](/isl/language/variables/)
+**Documentation:** [Objects](/isl/language/objects/), [Variables](/isl/language/variables/)
**Input:**
```json
@@ -203,7 +201,7 @@ or using `|map ( )`
**Use Case:** Create nested structure from flat data
-**Documentation:** [Objects](/isl/language/objects/) | [Variables](/isl/language/variables/)
+**Documentation:** [Objects](/isl/language/objects/), [Variables](/isl/language/variables/)
**Input:**
```json
@@ -252,7 +250,7 @@ or using `|map ( )`
**Use Case:** Include fields based on conditions
-**Documentation:** [Conditions](/isl/language/conditions/) | [Objects](/isl/language/objects/)
+**Documentation:** [Conditions](/isl/language/conditions/), [Objects](/isl/language/objects/)
**Input:**
```json
@@ -290,7 +288,7 @@ or using `|map ( )`
**Use Case:** Convert array of key-value pairs to object
-**Documentation:** [Functions](/isl/language/functions/) | [Built-in Modifiers](/isl/language/modifiers/) | [Loops](/isl/language/loops/)
+**Documentation:** [Functions](/isl/language/functions/), [Built-in Modifiers](/isl/language/modifiers/), [Loops](/isl/language/loops/)
**Input:**
```json
@@ -306,12 +304,15 @@ or using `|map ( )`
**ISL Transformation:**
```isl
fun run($input) {
- $result: $input | to.object; // convert any [{key/value}] to object
+ $result: $input.attributes | to.object; // convert any [{key/value}] to object
- // alternatively use the foreach
- foreach $attr in $input.attributes
- $result.`$attr.key`: $attr.value;
- endfor
+ // alternatively use the foreach - not as efficient
+ // foreach $attr in $input.attributes
+ // $result = {
+ // ...$result,
+ // `${ $attr.key }`: $attr.value
+ // }
+ // endfor
return $result;
}
@@ -332,7 +333,7 @@ fun run($input) {
**Use Case:** Convert object to a Key/Value array
-**Documentation:** [Functions](/isl/language/functions/) | [Built-in Modifiers](/isl/language/modifiers/)
+**Documentation:** [Functions](/isl/language/functions/), [Built-in Modifiers](/isl/language/modifiers/)
**Input:**
```json
@@ -381,7 +382,7 @@ fun run($input) {
**Use Case:** Filter array and transform matching items
-**Documentation:** [Loops](/isl/language/loops/) | [Built-in Modifiers](/isl/language/modifiers/) | [Conditions](/isl/language/conditions/)
+**Documentation:** [Loops](/isl/language/loops/), [Built-in Modifiers](/isl/language/modifiers/), [Conditions](/isl/language/conditions/)
**Input:**
```json
@@ -422,7 +423,7 @@ fun run($input) {
**Use Case:** Combine data from multiple input sources
-**Documentation:** [Objects](/isl/language/objects/) | [Variables](/isl/language/variables/)
+**Documentation:** [Objects](/isl/language/objects/), [Variables](/isl/language/variables/)
**Input:**
```json
@@ -507,7 +508,7 @@ fun run($input) {
**Use Case:** Real-world transformation of an order from external API format to internal format
-**Documentation:** [Loops](/isl/language/loops/) | [Math Expressions](/isl/language/math/) | [Dates & Times](/isl/types/dates/) | [Built-in Modifiers](/isl/language/modifiers/)
+**Documentation:** [Loops](/isl/language/loops/), [Math Expressions](/isl/language/math/), [Dates & Times](/isl/types/dates/), [Built-in Modifiers](/isl/language/modifiers/)
**Input:**
```json
@@ -598,7 +599,7 @@ fun run($input) {
## Date Processing
-**Documentation:** [Dates & Times](/isl/types/dates/) | [Built-in Modifiers](/isl/language/modifiers/)
+**Documentation:** [Dates & Times](/isl/types/dates/), [Built-in Modifiers](/isl/language/modifiers/)
### Parsing Dates with Multiple Formats
@@ -741,7 +742,7 @@ fun run($input) {
**Use Case:** Convert dates from one format to another
-**Documentation:** [Dates & Times](/isl/types/dates/) | [Built-in Modifiers](/isl/language/modifiers/)
+**Documentation:** [Dates & Times](/isl/types/dates/), [Built-in Modifiers](/isl/language/modifiers/)
**Input:**
```json
diff --git a/docs/ext/unit-testing/annotations.md b/docs/ext/unit-testing/annotations.md
index 05e015d..63d6a1b 100644
--- a/docs/ext/unit-testing/annotations.md
+++ b/docs/ext/unit-testing/annotations.md
@@ -5,33 +5,116 @@ grand_parent: Advanced Topics
nav_order: 3
---
-## Test
+# Test Annotations
-To create a unit test, we need to utilise the `@test` annotation to denote that the function
-is a unit test.
+## @test
-```kotlin
+Marks a function as a unit test. The function runs as a test case when tests are executed.
+
+### Basic form
+
+```isl
@test
fun test_addNumbers() {
- ...
+ $sum: 1 + 2;
+ @.Assert.equal(3, $sum);
+}
+```
+
+When no parameters are given, the function name is used as the test name.
+
+### Custom display name
+
+```isl
+@test("Addition of positive numbers")
+fun test_addNumbers() {
+ @.Assert.equal(3, 1 + 2);
}
```
-In the test function, we can write regular ISL code, along with additional functions specific for testing capabilities.
+### Name and group
+
+```isl
+@test("Check total", "math")
+fun test_total() {
+ @.Assert.equal(10, 5 + 5);
+}
+```
+
+### Object form
+
+```isl
+@test({ name: "Grouped test", group: "math" })
+fun test_grouped() {
+ $value: 30;
+ @.Assert.equal(30, $value);
+}
+```
+
+Use the object form when you need both a custom name and group.
+
+### Parameter summary
+
+| Form | Example | Result |
+|------|---------|--------|
+| No params | `@test` | Test name = function name |
+| String | `@test("My test")` | Test name = "My test" |
+| Two strings | `@test("Name", "group")` | Custom name and group |
+| Object | `@test({ name: "x", group: "y" })` | Custom name and group |
+
+## @setup
-## Setup
+Marks a function to run **before each test** in the same file. Use it for shared initialization.
-We can also use the `@setup` annotation to denote actions we wish to repeat for each unit tests (e.g. setup of something specific)
+- At most **one** `@setup` function per file
+- Runs before every `@test` function in that file
+- Does not run as a test itself
-```kotlin
+```isl
@setup
fun setup() {
- // Perform actions required before executing test
+ $sharedState: { count: 0 };
+ // This runs before each test
}
+@test
+fun test_first() {
+ // setup() already ran
+ @.Assert.equal(1, 1);
+}
@test
-fun test_addNumbers() {
- ...
+fun test_second() {
+ // setup() runs again before this test
+ @.Assert.equal(2, 2);
+}
+```
+
+## File structure
+
+A typical test file:
+
+```isl
+// Optional: imports
+import Helper from "../helper.isl";
+
+@setup
+fun setup() {
+ // Runs before each test
+}
+
+@test
+fun test_basic() {
+ // Test code
+}
+
+@test("Custom name")
+fun test_withName() {
+ // Test code
+}
+
+@test({ name: "Edge case", group: "validation" })
+fun test_edgeCase() {
+ // Test code
}
```
diff --git a/docs/ext/unit-testing/assertions.md b/docs/ext/unit-testing/assertions.md
index 4eef7c0..99ed4b1 100644
--- a/docs/ext/unit-testing/assertions.md
+++ b/docs/ext/unit-testing/assertions.md
@@ -5,100 +5,116 @@ grand_parent: Advanced Topics
nav_order: 2
---
-In order to verify values within the unit tests, we can utilise the Assertion framework.
+# Assertions
-These are accessible under the `@.Assert` namespace.
+Assertions verify values in unit tests. They are available under the `@.Assert` namespace. All assertion methods accept an optional third parameter `message` for custom failure output.
-## Equal
+## Equality
-### Description
+### equal
-Verifies whether or not values are equal.
+Verifies that two values are equal (deep equality for objects and arrays; property order is ignored for objects).
-### Syntax
+**Syntax:** `@.Assert.equal(expected, actual, message?)`
-`@.Assert.Equal($expectedValue, $actualValue, $msg)`
-
-- `$expectedValue`: Expected value.
-- `$actualValue`: Actual value to verify against.
-- `$msg`: Optional error message to show if assertion fails.
-
-### Example
-
-```kotlin
+```isl
@test
-fun assert_equals() {
- $var1 : 20
- @.Assert.Equal(40, $var1, "Values don't match.");
+fun test_equal() {
+ $var1: 20;
+ @.Assert.equal(20, $var1);
+
+ $obj1: { a: 1, b: 2 };
+ $obj2: { b: 2, a: 1 };
+ @.Assert.equal($obj1, $obj2); // Objects equal despite property order
}
```
-## NotEqual
-
-### Description
-
-Verifies whether or not values are not equal.
-
-### Syntax
+### notEqual
-`@.Assert.NotEqual($expectedValue, $actualValue, $msg)`
+Verifies that two values are not equal.
-- `$expectedValue`: Expected value.
-- `$actualValue`: Actual value to verify against.
-- `$msg`: Optional error message to show if assertion fails.
+**Syntax:** `@.Assert.notEqual(expected, actual, message?)`
-### Example
-
-```kotlin
+```isl
@test
-fun assert_equals() {
- $var1 : 20
- @.Assert.NotEqual(40, $var1, "Values match.");
+fun test_notEqual() {
+ $var1: 20;
+ @.Assert.notEqual(40, $var1, "Values should differ");
}
```
-## NotNull
+## Null Checks
-### Description
+### notNull
-Verifies whether or not value is not null.
+Verifies that a value is not null.
-### Syntax
+**Syntax:** `@.Assert.notNull(value, message?)`
-`@.Assert.NotNull($value, $msg)`
+```isl
+@test
+fun test_notNull() {
+ $var1: 42;
+ @.Assert.notNull($var1, "Value should not be null");
+}
+```
-- `$value`: Expected value.
-- `$msg`: Optional error message to show if assertion fails.
+### isNull
-### Example
+Verifies that a value is null.
-```kotlin
+**Syntax:** `@.Assert.isNull(value, message?)`
+
+```isl
@test
-fun assert_equals() {
- $var1 : null
- @.Assert.NotNull($var1, "Value is null");
+fun test_isNull() {
+ $var1: null;
+ @.Assert.isNull($var1, "Value should be null");
}
```
-## IsNull
+## Comparisons
+
+| Assertion | Description |
+|-----------|-------------|
+| `@.Assert.lessThan(a, b)` | a < b |
+| `@.Assert.lessThanOrEqual(a, b)` | a <= b |
+| `@.Assert.greaterThan(a, b)` | a > b |
+| `@.Assert.greaterThanOrEqual(a, b)` | a >= b |
-### Description
+## String and Pattern
-Verifies whether or not value is null.
+| Assertion | Description |
+|-----------|-------------|
+| `@.Assert.matches(pattern, value)` | value matches regex pattern |
+| `@.Assert.notMatches(pattern, value)` | value does not match pattern |
+| `@.Assert.contains(expected, actual)` | actual contains expected |
+| `@.Assert.notContains(expected, actual)` | actual does not contain expected |
+| `@.Assert.startsWith(prefix, value)` | value starts with prefix |
+| `@.Assert.notStartsWith(prefix, value)` | value does not start with prefix |
+| `@.Assert.endsWith(suffix, value)` | value ends with suffix |
+| `@.Assert.notEndsWith(suffix, value)` | value does not end with suffix |
-### Syntax
+## Membership and Type
-`@.Assert.IsNull($value, $msg)`
+| Assertion | Description |
+|-----------|-------------|
+| `@.Assert.in(value, collection)` | value is in collection |
+| `@.Assert.notIn(value, collection)` | value is not in collection |
+| `@.Assert.isType(value, type)` | value is of type (e.g. `number`, `string`, `array`, `node`, `date`) |
+| `@.Assert.isNotType(value, type)` | value is not of type |
-- `$value`: Expected value.
-- `$msg`: Optional error message to show if assertion fails.
+## Custom Failure Message
-### Example
+All assertions accept an optional third parameter for a custom message:
-```kotlin
+```isl
@test
-fun assert_equals() {
- $var1 : null
- @.Assert.NotNull($var1, "Value not null");
+fun test_withMessage() {
+ $var1: 1;
+ $var2: 2;
+ @.Assert.equal($var1, $var2, "Expected 1 to equal 2 - values mismatch");
}
```
+
+When the assertion fails, the custom message is included in the output.
diff --git a/docs/ext/unit-testing/index.md b/docs/ext/unit-testing/index.md
index 6e0ef59..f350efe 100644
--- a/docs/ext/unit-testing/index.md
+++ b/docs/ext/unit-testing/index.md
@@ -5,17 +5,170 @@ nav_order: 3
has_children: true
---
-**WIP**
+# ISL Unit Testing
-This section is dedicated to the unit testing framework in ISL.
+The ISL unit testing framework lets you write and run tests entirely in ISL. Tests live alongside your transformation code, use the same syntax, and can verify behavior without learning a separate testing language.
-The goal of this framework is to add reliability to ISL code, by allowing creation of tests
-to ensure critical behaviour within the script is maintained.
+## Quick Start
-The goal of the framework is to be able to create unit tests entirely within the ISL langauge,
-ensuring developers can make changes to the ISL tests when necessary, without the learning curve of
-learning a new testing methodology.
+1. Create an `.isl` file with `@test`-annotated functions:
-The syntax and structure of the ISL testing framework is inspired by JUnit.
+```isl
+@test
+fun test_simpleAssertion() {
+ $value: 42;
+ @.Assert.equal(42, $value);
+}
+```
-More reading on the implementation can be found here: [link](https://docs.google.com/document/d/1E0wlMy5XrKL0lpgtu6IKqQRdbBUIz3f2gLZb5tR064g/edit?usp=sharing)
+2. Run tests from the command line:
+
+```bash
+isl test
+# or specify a path
+isl test tests/
+isl test tests/sample.isl
+isl test tests/calculator.tests.yaml # YAML-driven suite
+```
+
+3. Or run tests programmatically from Kotlin/Java (see [Test Setup](setup.md)).
+
+## Two Ways to Define Tests
+
+- **Annotation-based** – In `.isl` files with `@setup` and `@test` (see [Test Annotations](annotations.md)).
+- **YAML-driven** – In `*.tests.yaml` files: specify `setup.islSource`, optional mocks, and a list of tests with `functionName`, `input`, and `expected`. No ISL test code required. See [YAML-Driven Test Suites](yaml-tests.md) for the full format, including `assertOptions` and `mockSource`/`mocks`.
+
+## What You Can Test
+
+- **Transformations** – Call your ISL functions and assert on the output
+- **Conditions** – Verify branching logic, edge cases
+- **Modifiers** – Test `| trim`, `| map`, `| filter`, etc.
+- **External integrations** – Use [mocking](mocking.md) to replace `@.Call.Api` and similar
+
+## File Format and Structure
+
+Tests are written in standard `.isl` files. A test file typically contains:
+
+- One optional `@setup` function (runs before each test)
+- One or more `@test` functions (each is a test case)
+
+```isl
+@setup
+fun setup() {
+ $x: 1; // Shared setup runs before each test
+}
+
+@test
+fun test_basic() {
+ @.Assert.equal(1, 1);
+}
+
+@test("Custom display name")
+fun test_withName() {
+ @.Assert.equal(2, 2);
+}
+
+@test({ name: "Grouped test", group: "math" })
+fun test_grouped() {
+ @.Assert.equal(3, 3);
+}
+```
+
+File discovery:
+
+- **CLI**: `isl test` finds all `.isl` files (by default `**/*.isl`) containing `@setup` or `@test`
+- **API**: You pass the list of files to `TransformTestPackageBuilder`
+
+## Attributes (Annotations)
+
+| Attribute | Description |
+|-----------|-------------|
+| `@test` | Marks a function as a test. Runs as a test case. |
+| `@test("Name")` | Same, with a custom display name |
+| `@test(name, group)` | Custom name and group for organization |
+| `@test({ name: "x", group: "y" })` | Object form for name and group |
+| `@setup` | Marks a function to run before each test in the file (at most one per file) |
+
+See [Test Annotations](annotations.md) for details.
+
+## Assertions
+
+Use `@.Assert` to verify values:
+
+| Assertion | Description |
+|-----------|-------------|
+| `@.Assert.equal(expected, actual, message?)` | Deep equality (objects, arrays, primitives) |
+| `@.Assert.notEqual(expected, actual, message?)` | Values must differ |
+| `@.Assert.notNull(value, message?)` | Value must not be null |
+| `@.Assert.isNull(value, message?)` | Value must be null |
+| `@.Assert.contains(expected, actual)` | actual contains expected |
+| `@.Assert.matches(pattern, value)` | value matches regex |
+| `@.Assert.startsWith(prefix, value)` | value starts with prefix |
+| ... | See [Assertions](assertions.md) for the full list |
+
+## Loading Test Fixtures
+
+Use `@.Load.From(fileName)` to load JSON, YAML, or CSV files relative to the current ISL file:
+
+```isl
+@test
+fun test_withFixture() {
+ $data = @.Load.From("fixtures/input.json")
+ @.Assert.equal("expected", $data.name)
+
+ $config = @.Load.From("config.yaml")
+ @.Assert.equal(10, $config.count)
+
+ $rows = @.Load.From("fixtures/data.csv")
+ @.Assert.equal(2, $rows | length)
+}
+```
+
+Supported formats: `.json`, `.yaml`, `.yml`, `.csv` (all converted to JSON). See [Loading Fixtures](loading.md).
+
+## How to Run Tests
+
+### Command Line (Recommended)
+
+```bash
+isl test [path] [options]
+```
+
+- `path`: Directory, single file (e.g. `sample.isl` or `suite.tests.yaml`), or default: current directory
+- `--glob PATTERN`: Filter .isl files when path is a directory (YAML suites use `**/*.tests.yaml` when not set)
+- `-f, --function NAME`: Run only tests whose function name matches; use `file:function` for a specific file (e.g. `sample.isl:test_customer` or `calculator.tests.yaml:add`)
+- `-o, --output FILE`: Write results to JSON
+
+### Programmatic (Kotlin/Java)
+
+See [Test Setup](setup.md) for adding the `isl-test` dependency and running tests from code.
+
+## Mocking
+
+Mock external functions (e.g. `@.Call.Api`) so tests don't hit real services:
+
+```isl
+@test
+fun test_withMock() {
+ @.Mock.Func("Call.Api", { status: 200, body: "ok" })
+ $result = @.Call.Api("https://example.com")
+ @.Assert.equal(200, $result.status)
+}
+```
+
+See [Mocking](mocking.md) for parameter matching, indexed (sequential) returns, loading mocks from files, captures, and annotation mocks.
+
+## Test Output
+
+- **CLI**: Prints pass/fail per test, with failure messages and locations
+- **JSON**: Use `-o results.json` for machine-readable output
+- **Exit code**: 1 if any test failed, 0 if all passed
+
+## Next Steps
+
+- [Test Setup](setup.md) – CLI usage, dependencies, programmatic API
+- [YAML-Driven Test Suites](yaml-tests.md) – `*.tests.yaml` format, assertOptions, mocks
+- [Test Annotations](annotations.md) – `@test` and `@setup` in detail
+- [Assertions](assertions.md) – Full assertion reference
+- [Loading Fixtures](loading.md) – `@.Load.From` for JSON/YAML/CSV
+- [Mocking](mocking.md) – Mock functions and annotations
diff --git a/docs/ext/unit-testing/loading.md b/docs/ext/unit-testing/loading.md
new file mode 100644
index 0000000..ad346c3
--- /dev/null
+++ b/docs/ext/unit-testing/loading.md
@@ -0,0 +1,118 @@
+---
+title: Loading Fixtures
+parent: Unit Testing
+grand_parent: Advanced Topics
+nav_order: 5
+---
+
+# Loading Test Fixtures
+
+Use `@.Load.From(fileName)` to load JSON, YAML, or CSV files as test data. Paths are resolved **relative to the directory of the current ISL file**.
+
+## Syntax
+
+```isl
+$data = @.Load.From("fileName")
+```
+
+- `fileName` – Path relative to the current file (e.g. `fixtures/data.json`, `../shared/config.yaml`)
+
+## Supported Formats
+
+| Extension | Description |
+|-----------|-------------|
+| `.json` | Parsed as JSON |
+| `.yaml`, `.yml` | Parsed as YAML, converted to JSON |
+| `.csv` | Parsed as CSV; first row = headers, converted to array of objects |
+
+All formats are returned as JSON (objects or arrays) for use in assertions.
+
+## Examples
+
+### JSON
+
+**fixtures/user.json:**
+```json
+{"name": "Alice", "age": 30, "active": true}
+```
+
+**tests/user.isl:**
+```isl
+@test
+fun test_loadJson() {
+ $user = @.Load.From("fixtures/user.json")
+ @.Assert.equal("Alice", $user.name)
+ @.Assert.equal(30, $user.age)
+ @.Assert.equal(true, $user.active)
+}
+```
+
+### YAML
+
+**fixtures/config.yaml:**
+```yaml
+key: value
+nested:
+ count: 10
+ items: [a, b, c]
+```
+
+**tests/config.isl:**
+```isl
+@test
+fun test_loadYaml() {
+ $config = @.Load.From("fixtures/config.yaml")
+ @.Assert.equal("value", $config.key)
+ @.Assert.equal(10, $config.nested.count)
+ @.Assert.equal(3, $config.nested.items | length)
+}
+```
+
+### CSV
+
+**fixtures/data.csv:**
+```csv
+id,name,score
+1,Alice,100
+2,Bob,85
+```
+
+**tests/data.isl:**
+```isl
+@test
+fun test_loadCsv() {
+ $rows = @.Load.From("fixtures/data.csv")
+ @.Assert.equal(2, $rows | length)
+ @.Assert.equal("Alice", $rows[0].name)
+ @.Assert.equal(100, $rows[0].score)
+ @.Assert.equal("Bob", $rows[1].name)
+}
+```
+
+CSV is parsed with the first row as headers. Each subsequent row becomes an object with those headers as keys.
+
+## Path Resolution
+
+Paths are relative to the **directory of the current ISL file**:
+
+| Current file | `fileName` | Resolved path |
+|--------------|------------|---------------|
+| `tests/sample.isl` | `fixtures/data.json` | `tests/fixtures/data.json` |
+| `tests/sample.isl` | `../shared/config.yaml` | `shared/config.yaml` |
+| `tests/unit/sample.isl` | `../../fixtures/data.json` | `fixtures/data.json` |
+
+## Availability
+
+`@.Load.From` is available only in the **test context**:
+
+- ✅ When running tests via `isl test`
+- ✅ When running via `TransformTestPackage` with `basePath` passed to `TransformTestPackageBuilder`
+- ❌ In regular transform execution (e.g. `isl transform`)
+
+If `basePath` is not set (e.g. programmatic use without it), `Load.From` throws a clear error.
+
+## Error Handling
+
+- **File not found**: Throws with the resolved path
+- **Unsupported format**: Throws for extensions other than `.json`, `.yaml`, `.yml`, `.csv`
+- **Parse error**: Invalid JSON/YAML/CSV throws during parsing
diff --git a/docs/ext/unit-testing/mocking.md b/docs/ext/unit-testing/mocking.md
index 7a6f939..70c8976 100644
--- a/docs/ext/unit-testing/mocking.md
+++ b/docs/ext/unit-testing/mocking.md
@@ -1,9 +1,9 @@
---
-title: Mocking
+
+## title: Mocking
parent: Unit Testing
grand_parent: Advanced Topics
nav_order: 4
----
## Introduction
@@ -102,6 +102,97 @@ fun assert_mock() {
}
```
+### Indexed mocking (sequential returns)
+
+When a function is called multiple times with the same parameters, you can return different values per call by appending `#1`, `#2`, `#3`, etc. to the function name. Each index corresponds to the Nth invocation.
+
+- **Standard behaviour** – `@.Mock.Func("Data.GetData", value)` returns the same value on every call.
+- **Indexed behaviour** – `@.Mock.Func("Data.GetData#1", value1)` returns `value1` on the first call, `@.Mock.Func("Data.GetData#2", value2)` on the second, and so on.
+
+On exhaustion (when the function is called more times than defined), the mock fails with a clear error.
+
+#### Example
+
+```kotlin
+@test
+fun assert_indexed_mock() {
+ @.Mock.Func("Data.GetData#1", [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 } ])
+ @.Mock.Func("Data.GetData#2", [ { id: 1 }, { id: 2 }, { id: 3 } ])
+ @.Mock.Func("Data.GetData#3", [])
+
+ $r1 : @.Data.GetData()
+ $r2 : @.Data.GetData()
+ $r3 : @.Data.GetData()
+
+ @.Assert.equal(5, $r1 | length)
+ @.Assert.equal(3, $r2 | length)
+ @.Assert.equal(0, $r3 | length)
+}
+```
+
+Indexed mocking works for both `@.Mock.Func` and `@.Mock.Annotation`. When using `@.Mock.GetFuncCaptures` or `@.Mock.GetAnnotationCaptures`, you can pass the base name (with or without `#index`); captures are associated with the base function.
+
+### Loading mocks from a file
+
+Use `@.Mock.Load(relativeFileName)` to load mocks from a YAML or JSON file. The path is resolved relative to the directory of the current ISL file (same as `@.Load.From`).
+
+The file format mirrors the existing mock functions:
+
+```yaml
+func:
+ - name: "Data.GetData#1"
+ return: [ { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 } ]
+ - name: "Data.GetData#2"
+ return: [ { id: 1 }, { id: 2 }, { id: 3 } ]
+ - name: "Data.GetData#3"
+ return: []
+ - name: "Api.Call"
+ return: { status: 200, body: "ok" }
+ params: [ "https://example.com" ] # optional parameter matching
+
+ # ISL mock: define an ISL function and run it as the mock (compiled on the fly)
+ - name: "mask"
+ isl: |
+ fun mask( $value ) {
+ return `xxxxxx$value`;
+ }
+
+ # Pipe modifier: "Modifier.[Name]" mocks "| name" (e.g. $x | mask2)
+ - name: "Modifier.mask2"
+ isl: |
+ fun mask2( $value ) {
+ return `xxxxxx$value`;
+ }
+
+annotation:
+ - name: "mask2"
+ result: "masked"
+ # - name: "cache#1"
+ # return: "cached-value"
+```
+
+- `func` and `annotation` are arrays of mock entries.
+- Each entry has `name` (required), and either `return`/`result` (static value) or `isl` (ISL snippet).
+- **Pipe modifiers** (`| name`): to mock a pipe modifier like `$x | mask2`, add a **func** entry with name `Modifier.[Name]` (e.g. `Modifier.mask2`). The mock receives the left-hand value as the first parameter.
+- **Annotations** (`@name` on a function): use the `annotation` array with the annotation name (e.g. `mask2`). This mocks the decorator when you write `@mask2 fun foo() { ... }`; it does not mock the pipe `| mask2`.
+- `**isl**`: Multiline ISL source defining one or more functions. The first function is compiled and executed as the mock; when the mock is called, that function runs with the call's parameters bound to its arguments. Use this for dynamic or computed mock behaviour.
+- Optionally `params` (array of values to match) for parameter-based matching.
+- Use `#1`, `#2`, etc. in the name for indexed (sequential) returns.
+- Supports `.yaml`, `.yml`, and `.json` files.
+
+#### Example
+
+```kotlin
+@test
+fun test_with_loaded_mocks() {
+ @.Mock.Load("mocks/api-mocks.yaml")
+ $r1 : @.Data.GetData()
+ $r2 : @.Data.GetData()
+ @.Assert.equal(5, $r1 | length)
+ @.Assert.equal(3, $r2 | length)
+}
+```
+
### Capturing parameter inputs
We can also obtain all of the parameters passed into the function during the test. The parameters are stored in chronological order.
@@ -142,6 +233,30 @@ $allPublished = @.Mock.GetFuncCaptures('Event.Publish')
## Functions
+### Load
+
+#### Description
+
+Loads mocks from a YAML or JSON file. The path is relative to the directory of the current ISL file.
+
+#### Syntax
+
+`@.Mock.Load($fileName)`
+
+- `$fileName`: Relative path to the mock file (e.g. `"mocks/api.yaml"`). Supports `.yaml`, `.yml`, and `.json`.
+- `Returns`: null
+
+#### Example
+
+```kotlin
+@test
+fun test_with_mocks() {
+ @.Mock.Load("mocks/api-mocks.yaml")
+ $result = @.Api.Call("https://example.com")
+ @.Assert.equal(200, $result.status)
+}
+```
+
### Func
#### Description
@@ -152,10 +267,10 @@ Creates a mock of an ISL function.
`@.Mock.Func($funcToMock, $returnValue, ...$paramsToMatch)`
-- `$funcToMock`: ISL function to mock.
+- `$funcToMock`: ISL function to mock. Use `Function.Name#1`, `Function.Name#2`, etc. for indexed (sequential) returns per call.
- `$returnValue`: Return value of mock.
-- `$paramsToMatch`: Optional error message to show if assertion fails.
-- `Returns`: Unique id of the mock.
+- `$paramsToMatch`: Optional parameters to match. When omitted, matches any parameters.
+- `Returns`: Unique id of the mock (null for default mocks).
#### Example
@@ -186,10 +301,10 @@ Currently has no functionality, but can be used to:
`@.Mock.Annotation($funcToMock, $returnValue, ...$paramsToMatch)`
-- `$funcToMock`: ISL annotation to mock.
+- `$funcToMock`: ISL annotation to mock. Use `AnnotationName#1`, `AnnotationName#2`, etc. for indexed (sequential) returns per call.
- `$returnValue`: Return value of mock.
-- `$paramsToMatch`: Optional error message to show if assertion fails.
-- `Returns`: Unique id of the mock.
+- `$paramsToMatch`: Optional parameters to match. When omitted, matches any parameters.
+- `Returns`: Unique id of the mock (null for default mocks).
#### Example
@@ -404,3 +519,4 @@ fun test_function() {
// ]
}
```
+
diff --git a/docs/ext/unit-testing/setup.md b/docs/ext/unit-testing/setup.md
index 61d339e..a967634 100644
--- a/docs/ext/unit-testing/setup.md
+++ b/docs/ext/unit-testing/setup.md
@@ -5,52 +5,132 @@ grand_parent: Advanced Topics
nav_order: 1
---
-## Setting up your first test
+# Test Setup
-In order to utilise the library, add the following to your `pom.xml`
+## Running Tests via CLI (Recommended)
+
+The easiest way to run ISL tests is with the `isl test` command:
+
+```bash
+# Run tests in current directory (discovers **/*.isl with @test or @setup)
+isl test
+
+# Run tests in a specific directory
+isl test tests/
+
+# Run a specific file
+isl test tests/sample.isl
+
+# Custom glob pattern (applies to .isl files; YAML suites use **/*.tests.yaml when not set)
+isl test tests/ --glob "**/*.test.isl"
+
+# Run only specific test function(s) (by function name or file:function)
+isl test . -f add -f test_customer
+isl test . -f calculator.tests.yaml:add -f sample.isl:test_simpleAssertion
+
+# Write results to JSON
+isl test -o results.json
+```
+
+The CLI discovers:
+
+- **.isl files** containing `@setup` or `@test` annotations (default glob: `**/*.isl`)
+- **\*.tests.yaml** (or \*.tests.yml) YAML-driven test suites (default glob: `**/*.tests.yaml`)
+
+Both are run when you pass a directory (e.g. `isl test .`).
+
+## Running Tests Programmatically
+
+### Maven
+
+Add the `isl-test` dependency:
```xml
- com.intuit.isl
- isl-test
- ...
+ com.intuit.isl
+ isl-test
+ 1.1.19+
```
-To evoke the unit test files, run the following:
-
-### Kotlin
+### Gradle (Kotlin DSL)
```kotlin
-val builder = TransformPackageBuilder()
-
-val scripts : List
+dependencies {
+ implementation("com.intuit.isl:isl-test:1.1.19+")
+}
+```
-val transformPackage = builder.build(fileInfo)
+### Kotlin Example
-val transformTestPackage = TransformTestPackage(transformPackage)
+```kotlin
+import com.intuit.isl.runtime.FileInfo
+import com.intuit.isl.test.TransformTestPackageBuilder
+import java.nio.file.Path
+import java.nio.file.Paths
+
+// Build test package from files
+val basePath = Paths.get(".").toAbsolutePath()
+val fileInfos = listOf(
+ FileInfo("tests/sample.isl", Path.of("tests/sample.isl").toFile().readText())
+).toMutableList()
+
+val testPackage = TransformTestPackageBuilder().build(
+ fileInfos,
+ findExternalModule = null,
+ basePath = basePath // Required for @.Load.From
+)
// Run all tests
-val testResults = testPackage.runAllTests()
+val results = testPackage.runAllTests()
+
+// Run a specific test
+val singleResult = testPackage.runTest("tests/sample.isl", "test_simpleAssertion")
-// Run a specific test function within defined test file.
-val individualTestResult = testPackage.runTest("test.isl", "test")
+// Check results
+results.testResults.forEach { tr ->
+ println("${tr.testName}: ${if (tr.success) "PASS" else "FAIL"} ${tr.message ?: ""}")
+}
```
-### Java
+### Java Example
```java
-TransformPackageBuilder builder = new TransformPackageBuilder();
+import com.intuit.isl.runtime.FileInfo;
+import com.intuit.isl.test.TransformTestPackageBuilder;
+import com.intuit.isl.test.annotations.TestResultContext;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.ArrayList;
+
+Path basePath = Paths.get(".").toAbsolutePath();
+List fileInfos = new ArrayList<>();
+fileInfos.add(new FileInfo("tests/sample.isl", Files.readString(Path.of("tests/sample.isl"))));
+
+var builder = new TransformTestPackageBuilder();
+var testPackage = builder.build(fileInfos, null, basePath);
+
+TestResultContext results = testPackage.runAllTests();
+
+// Run specific test
+TestResultContext singleResult = testPackage.runTest("tests/sample.isl", "test_simpleAssertion");
+```
-List scripts;
+## basePath and Load.From
-TransformPackage transformPackage = builder.build(fileInfo)
+When running tests programmatically, pass `basePath` to `TransformTestPackageBuilder.build()` so that `@.Load.From(fileName)` can resolve relative paths correctly. If `basePath` is `null`, `Load.From` will throw when used.
-TransformTestPackage transformTestPackage = new TransformTestPackage(transformPackage)
+## Test Result Structure
-// Run all tests
-TestResultContext testResults = testPackage.runAllTests()
+`TestResultContext` contains:
-// Run a specific test function within defined test file.
-TestResultContext individualTestResult = testPackage.runTest("test.isl", "test")
-```
+- `testResults` – List of `TestResult` with:
+ - `testFile` – Source file
+ - `functionName` – Function name
+ - `testName` – Display name (from `@test` or function name)
+ - `testGroup` – Group (from `@test` or file name)
+ - `success` – Whether the test passed
+ - `message` – Failure message if any
+ - `errorPosition` – File/line/column if available
diff --git a/docs/ext/unit-testing/yaml-tests.md b/docs/ext/unit-testing/yaml-tests.md
new file mode 100644
index 0000000..94c312d
--- /dev/null
+++ b/docs/ext/unit-testing/yaml-tests.md
@@ -0,0 +1,280 @@
+---
+title: YAML-Driven Test Suites
+parent: Unit Testing
+grand_parent: Advanced Topics
+nav_order: 2
+---
+
+# YAML-Driven Test Suites
+
+You can define unit tests in **\*.tests.yaml** (or \*.tests.yml) files without writing ISL test code. A YAML suite specifies the ISL module to test, optional mocks, and a list of test cases with `functionName`, `input`, and `expected` result. The runner invokes each function and compares the result to `expected` using configurable comparison options.
+
+## When to Use YAML Suites
+
+- **Data-heavy tests** – Many input/expected pairs without custom logic
+- **Non-ISL authors** – QA or product can add tests by editing YAML
+- **Shared mocks** – Reuse `mockSource` and inline `mocks` across many tests
+- **CI / tooling** – Generate or parse `*.tests.yaml` from other systems
+
+You can mix YAML suites with [annotation-based tests](annotations.md) in the same project; `isl test .` runs both.
+
+## File and Discovery
+
+- **Naming**: `*.tests.yaml` or `*.tests.yml` (e.g. `calculator.tests.yaml`).
+- **Discovery**: When you run `isl test `, the CLI finds all such files under the path (default glob: `**/*.tests.yaml`).
+- **Single file**: `isl test path/to/suite.tests.yaml` runs only that suite.
+
+All paths inside the YAML file (`islSource`, `mockSource`) are **relative to the directory containing the `.tests.yaml` file**.
+
+## Suite Structure
+
+```yaml
+category: my-group-name # optional; used as test group in output
+setup:
+ islSource: mymodule.isl # ISL file to test (required)
+ mockSource: optional.yaml # optional; see Mocks below
+ mocks: # optional inline mocks; see Mocks below
+ func: [ ... ]
+assertOptions: # optional; see Assert options below
+ nullSameAsMissing: true
+tests: # or islTests (same meaning)
+ - name: test display name
+ functionName: myFunction
+ input: 42 # or object for multiple params
+ expected: { result: 42 }
+```
+
+- **category** – Label for the suite in results (e.g. `[ISL Result] my-group-name`). If omitted, the suite file name (without extension) is used.
+- **setup** – Required. Contains `islSource` and optionally `mockSource` and `mocks`.
+- **assertOptions** – Optional. Controls how `expected` is compared to the function result. Can be set at suite level and overridden per test.
+- **tests** / **islTests** – List of test entries. Either key is accepted.
+
+## Setup
+
+### islSource (required)
+
+The ISL file to load and run. Path is relative to the directory of the `.tests.yaml` file.
+
+```yaml
+setup:
+ islSource: calculator.isl
+```
+
+### mockSource (optional)
+
+Mock definitions loaded from file(s), in the same format as `@.Mock.Load`. Paths are relative to the suite directory.
+
+- **Single file**: `mockSource: ../mocks/sample-mocks.yaml`
+- **Multiple files** (loaded in order; later overrides earlier):
+ `mockSource: [common.yaml, overrides.yaml]`
+
+Supported extensions: `.json`, `.yaml`, `.yml`.
+
+### mocks (optional, inline)
+
+Inline mocks applied **after** `mockSource`, so they override or add to file-based mocks. Same structure as in `@.Mock.Load`: `func` and/or `annotation` arrays.
+
+```yaml
+setup:
+ islSource: service.isl
+ mockSource: ../mocks/sample-mocks.yaml
+ mocks:
+ func:
+ - name: "Api.Call"
+ return: { status: 200, body: "overridden" }
+```
+
+All mocks are additive; parameter lists differentiate overloads.
+
+## Test Entries
+
+Each entry under `tests` (or `islTests`) has:
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| **name** | Yes | Display name in results |
+| **functionName** | Yes | ISL function to call |
+| **input** | No | Input to the function. Single value for single-param; object with param names as keys for multiple params |
+| **ignore** | No | JSON paths to ignore when comparing expected vs actual (exact path match). Use dot notation; array indices as `[0]`, `[1]`, etc. |
+| **expected** | No | Expected return value (JSON). Omitted or `null` means expect `null` |
+| **byPassAnnotations** | No | If `true`, bypass annotation processing (optional) |
+| **assertOptions** | No | Override suite `assertOptions` for this test only. Same formats as suite (object, comma-separated list, or array of option names) |
+
+### Input format
+
+- **Single-parameter function**: `input` can be a scalar or object (passed as that one argument).
+- **Multi-parameter function**: `input` must be an object; keys are parameter names (with or without `$`). Values are passed as the corresponding arguments.
+
+```yaml
+# Single param
+- name: double a number
+ functionName: double
+ input: 7
+ expected: 14
+
+# Multiple params
+- name: add two numbers
+ functionName: add
+ input:
+ a: 2
+ b: 3
+ expected: 5
+```
+
+### Ignoring JSON paths (ignore)
+
+To skip comparison at specific paths (e.g. dynamic or non-deterministic fields), set **ignore** above **expected**. Paths use dot notation; array indices use `[0]`, `[1]`, etc.
+
+```yaml
+- name: response with ignored fields
+ functionName: callApi
+ input: { id: 1 }
+ ignore:
+ - providerResponses.error.detail
+ - providerResponses.items[0].uid
+ expected:
+ status: 200
+ providerResponses:
+ error: {}
+ items:
+ - { name: "first" }
+```
+
+Only the listed paths are ignored (exact match); the rest of the object is compared as usual.
+
+When a test fails, the failure output includes **Result Differences** (expected vs actual and per-path diffs). If the test entry has **ignore** set, that output also lists **Ignored path(s)** so you can see which paths were skipped during comparison.
+
+## Assert Options (assertOptions)
+
+Assert options control how the actual function result is compared to `expected`. By default, comparison is strict (exact match). You can relax it at the **suite** level and optionally **per test**.
+
+### Where to set assertOptions
+
+- **Suite level**: under the root key `assertOptions`. Applies to all tests in the suite unless a test overrides.
+- **Per test**: under a test entry as `assertOptions`. Overrides the suite options for that test only.
+
+### Formats
+
+You can write `assertOptions` in three ways:
+
+**1. Object (explicit booleans):**
+
+```yaml
+assertOptions:
+ nullSameAsMissing: true
+ ignoreExtraFieldsInActual: true
+```
+
+**2. Comma-separated list of option names:**
+
+```yaml
+assertOptions: nullSameAsMissing, nullSameAsEmptyArray, missingSameAsEmptyArray, ignoreExtraFieldsInActual, numbersEqualIgnoreFormat
+```
+
+**3. Array of option names:**
+
+```yaml
+assertOptions:
+ - nullSameAsMissing
+ - nullSameAsEmptyArray
+ - missingSameAsEmptyArray
+ - ignoreExtraFieldsInActual
+ - numbersEqualIgnoreFormat
+```
+
+### Option reference
+
+All options default to `false` (strict comparison). Supported options:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| **nullSameAsMissing** | `false` | Treat `null` and missing (absent key) as equal |
+| **nullSameAsEmptyArray** | `false` | Treat `null` and empty array `[]` as equal |
+| **missingSameAsEmptyArray** | `false` | Treat missing (absent key) and empty array `[]` as equal |
+| **ignoreExtraFieldsInActual** | `false` | Only compare keys present in `expected`; ignore extra keys in actual |
+| **numbersEqualIgnoreFormat** | `false` | Compare numbers by numeric value only (e.g. `1234`, `1234.0`, `1234.00` are equal) |
+
+### Example: suite and per-test override
+
+```yaml
+category: api
+setup:
+ islSource: api.isl
+assertOptions:
+ ignoreExtraFieldsInActual: true
+ numbersEqualIgnoreFormat: true
+tests:
+ - name: strict comparison for this test
+ functionName: getExact
+ input: 1
+ expected: { id: 1, name: "x" }
+ assertOptions: {} # or omit; use only suite options
+
+ - name: allow extra fields and null as missing
+ functionName: getPartial
+ input: 2
+ expected: { id: 2 }
+ assertOptions:
+ nullSameAsMissing: true
+ ignoreExtraFieldsInActual: true
+```
+
+## Running YAML Suites
+
+### Command line
+
+```bash
+# Run all tests (YAML suites + .isl tests under current dir)
+isl test .
+
+# Run a single YAML suite
+isl test path/to/calculator.tests.yaml
+
+# Run only tests whose function name matches
+isl test . -f add -f double
+
+# Run a specific test in a specific suite (suiteFile:functionName)
+isl test . -f calculator.tests.yaml:add
+```
+
+The `-f` / `--function` filter applies to both annotation-based tests and YAML suites. For YAML, you can use `functionName` or `suiteFile:functionName` (e.g. `calculator.tests.yaml:add`).
+
+### Output
+
+- Pass/fail per test with the entry’s `name` (and `functionName` in brackets when different).
+- On failure, a comparison message shows expected vs actual and, when available, path-level differences (e.g. `$.field.[0].key`). If the test uses **ignore**, the failure output also lists the ignored path(s).
+- Use `-o results.json` for machine-readable results.
+
+## Full Example
+
+**calculator.isl** (snippet):
+
+```isl
+fun add($a, $b) { $a + $b }
+fun double($x) { $x * 2 }
+```
+
+**calculator.tests.yaml**:
+
+```yaml
+category: calculator
+setup:
+ islSource: calculator.isl
+tests:
+ - name: add two numbers
+ functionName: add
+ input: { a: 2, b: 3 }
+ expected: 5
+ - name: double a number
+ functionName: double
+ input: 7
+ expected: 14
+```
+
+Run: `isl test calculator.tests.yaml` or `isl test .`
+
+## See also
+
+- [Test Setup](setup.md) – CLI discovery, `-f`, `-o`
+- [Mocking](mocking.md) – Mock format for `mockSource` and `mocks`
+- [Test Annotations](annotations.md) – `@test` / `@setup` in .isl files
diff --git a/docs/img/favicon.svg b/docs/img/favicon.svg
index 7e9660e..58bbd5b 100644
--- a/docs/img/favicon.svg
+++ b/docs/img/favicon.svg
@@ -4,18 +4,18 @@
+ fill="#2563FF"/>
diff --git a/docs/index.md b/docs/index.md
index aec052e..9ce17bd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -124,6 +124,7 @@ Will output:
- Support for [parsing XML](./types/xml.md#xml-processing) and [outputting XML](./types/xml.md#xml-output), [parsing CSV](./types/csv.md#csv-processing) or yaml.
- Support for advanced String Interpolation `Hi there $name. Today is ${ @.Date.Now() | to.string("yyyy MM dd") }. `.
- Support for [`find`, `match` and `replace` using Regular Expressions](./language/modifiers.md#regex-processing).
+- [Unit Testing](./ext/unit-testing/index.md) – Write and run tests in ISL with `@test`, assertions, fixtures (`@.Load.From`), and mocking.
## Learning And Support
diff --git a/docs/java.start.md b/docs/java.start.md
index afe60aa..85e2565 100644
--- a/docs/java.start.md
+++ b/docs/java.start.md
@@ -26,7 +26,7 @@ This guide shows you how to embed ISL in your Java/Kotlin project.
```kotlin
dependencies {
- implementation("com.intuit.isl:isl-transform:2.4.20-SNAPSHOT")
+ implementation("com.intuit.isl:isl-transform:1.1.0-SNAPSHOT")
}
```
diff --git a/docs/overview.md b/docs/overview.md
index 53e2b5f..7edf4e7 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -80,7 +80,7 @@ Will output:
-# Structure
+## Structure
ISL Code is structured as any programming language in multiple sections:
1. `import` of other ISL files
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b4088da..00fa33c 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -5,4 +5,8 @@ nav_order: 101
What's upcoming for ISL:
-- [WIP] Improved Transformation Performance.
+- Support for variable level dynamic properties
+ ```isl
+ $input.`$key` = $value
+ ```
+- Improved Transformation Performance.
diff --git a/gradle.properties b/gradle.properties
index b6893a4..ffa2274 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -7,9 +7,5 @@ org.gradle.caching=true
kotlin.code.style=official
# Version
-version=2.4.20-SNAPSHOT
-
-# Optional: Nexus credentials (can also be set via environment variables)
-# nexusUsername=your-username
-# nexusPassword=your-password
+version=1.1.0
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1af9e09..aaaabb3 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/isl-cmd/README.md b/isl-cmd/README.md
index 0db4119..94a06cb 100644
--- a/isl-cmd/README.md
+++ b/isl-cmd/README.md
@@ -66,12 +66,53 @@ isl transform script.isl --function processData -i input.json
### Validate Command
-Check if a script is syntactically valid:
+Check if a script is syntactically valid (supports relative imports like `../customer.isl`):
```bash
isl validate script.isl
```
+### Test Command
+
+Run ISL tests from:
+
+- **.isl files** with `@setup` and `@test` annotations
+- **\*.tests.yaml files** (YAML-driven unit test suites)
+
+```bash
+# Run all tests in current directory (both .isl and *.tests.yaml)
+isl test .
+
+# Run a specific YAML suite
+isl test path/to/suite.tests.yaml
+
+# Run specific test functions
+isl test . -f test_customer -f test_simpleAssertion
+
+# Target a specific file:function
+isl test . -f sample.isl:test_customer
+```
+
+**YAML test suite format** (e.g. `mymodule.tests.yaml`):
+
+```yaml
+category: name of test group
+setup:
+ islSource: name of ISL file to test (e.g. mymodule.isl)
+ mockSource: optional mock file(s) — single path (e.g. mymocks.yaml) or array (e.g. [commonMocks.yaml, otherMocks.yaml]); loaded in order, each overrides the previous; same format as @.Mock.Load; paths relative to suite directory
+ mocks: optional inline mocks (func/annotation arrays); applied after mockSource so they override
+assertOptions: optional — assertion comparison options for the whole suite (object, or comma-separated/array of: nullSameAsMissing, nullSameAsEmptyArray, missingSameAsEmptyArray, ignoreExtraFieldsInActual, numbersEqualIgnoreFormat)
+tests: # or islTests
+ - name: unit test name
+ functionName: function to call
+ byPassAnnotations: false # optional
+ input: 42 # single value for single-param; or object with param names as keys
+ expected: { "result": 42 } # expected JSON result
+ assertOptions: optional # override suite assertOptions for this test only (same formats)
+```
+
+Paths in `setup` (`islSource`, `mockSource` file names) are relative to the directory containing the `.tests.yaml` file. For full details (assertOptions reference, input format, filtering) see the [Unit Testing — YAML-Driven Test Suites](../docs/ext/unit-testing/yaml-tests.md) doc.
+
### Info Command
Show version and system information:
@@ -313,6 +354,21 @@ isl transform step3.isl -i temp2.json -o final-result.json
- `debug=true` - Enable debug output and stack traces
+### Command Comparison: transform, validate, test
+
+All three commands use the same **module resolution** via `IslModuleResolver`, so relative imports (e.g. `import Customer from "../customer.isl"`) work consistently:
+
+| Aspect | transform | validate | test |
+|--------|-----------|----------|------|
+| **Input** | Single script file | Single script file | Directory or file (discovers .isl with @test) |
+| **Compilation** | `IslModuleResolver.compileSingleFile()` | Same | `TransformTestPackageBuilder` with `createModuleResolver()` (uses `IslModuleResolver.resolveExternalModule`) |
+| **Module resolution** | Relative to script dir | Same | Relative to search base; checks discovered files first |
+| **Execution** | Runs specified function (default: `run`) | Runs `run` to validate | Runs @test functions (with @setup) |
+| **Context** | OperationContext + vars, input, Log | Empty OperationContext | TestOperationContext + Log, Assert, Mock |
+| **Output** | JSON/YAML result | Success message | Test results (pass/fail) |
+
+**Shared behavior:** All commands resolve `../module.isl` and `./module.isl` relative to the current script's directory. Test additionally resolves against already-discovered test files.
+
## Development
### Running in Development
diff --git a/isl-cmd/build.gradle.kts b/isl-cmd/build.gradle.kts
index f748278..74f6be8 100644
--- a/isl-cmd/build.gradle.kts
+++ b/isl-cmd/build.gradle.kts
@@ -17,6 +17,7 @@ dependencies {
// ISL modules
implementation(project(":isl-transform"))
implementation(project(":isl-validation"))
+ implementation(project(":isl-test"))
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
@@ -47,7 +48,7 @@ dependencies {
// Configure Shadow JAR for fat JAR creation
tasks.shadowJar {
archiveBaseName.set("isl")
- archiveClassifier.set("")
+ archiveClassifier.set("all")
archiveVersion.set(project.version.toString())
manifest {
@@ -74,6 +75,7 @@ tasks.register("runIsl") {
description = "Run ISL CLI with arguments"
classpath = sourceSets.main.get().runtimeClasspath
mainClass.set("com.intuit.isl.cmd.IslCommandLineKt")
+ workingDir = project.findProperty("runWorkingDir")?.toString()?.takeIf { it.isNotBlank() }?.let { file(it) } ?: rootProject.projectDir
// Allow passing arguments: ./gradlew :isl-cmd:runIsl --args="script.isl"
if (project.hasProperty("args")) {
@@ -81,6 +83,11 @@ tasks.register("runIsl") {
}
}
+// Application run task: use invocation directory when set (e.g. from isl.bat/isl.sh)
+tasks.named("run").configure {
+ workingDir = project.findProperty("runWorkingDir")?.toString()?.takeIf { it.isNotBlank() }?.let { file(it) } ?: rootProject.projectDir
+}
+
// Configure JAR manifest
tasks.jar {
manifest {
@@ -92,3 +99,5 @@ tasks.jar {
}
}
+// Publishing is configured automatically by the maven publish plugin
+
diff --git a/isl-cmd/examples/README.md b/isl-cmd/examples/README.md
new file mode 100644
index 0000000..792a391
--- /dev/null
+++ b/isl-cmd/examples/README.md
@@ -0,0 +1,43 @@
+# ISL CLI Examples
+
+Sample scripts and tests for the ISL command-line interface.
+
+## Contents
+
+| File | Description |
+|------|-------------|
+| `hello.isl` | Simple greeting transform |
+| `transform.isl` | Data transformation with filter/reduce |
+| `tests/calculator.isl` | Pure functions for unit testing (add, double, greet, echo) |
+| `tests/service.isl` | Functions that use mocks (Api.Call, Config.GetLimit, Data.GetItems) |
+| `tests/calculator.tests.yaml` | YAML-driven unit tests for `calculator.isl` |
+| `tests/service.tests.yaml` | YAML-driven unit tests for `service.isl` (uses `../mocks/sample-mocks.yaml`) |
+| `tests/sample.isl` | Annotation-based tests (`@setup`, `@test`) with `@.Mock.Load` |
+| `mocks/sample-mocks.yaml` | Mock definitions (same format as `@.Mock.Load`) |
+
+## Running examples
+
+From the **examples** directory (e.g. after `cd isl-cmd/examples`):
+
+```bash
+# Transform
+isl transform hello.isl -i hello-input.json --pretty
+isl transform transform.isl -i data.json --pretty
+
+# Validate
+isl validate transform.isl
+
+# Run all tests (YAML suites + annotation-based .isl tests)
+isl test .
+```
+
+To run only a specific YAML suite:
+
+```bash
+isl test tests/calculator.tests.yaml
+isl test tests/service.tests.yaml
+```
+
+## YAML test format
+
+`*.tests.yaml` suites use: `category`, `setup.islSource`, optional `setup.mockSource` (single path or array) and `setup.mocks` (inline), optional suite-level `assertOptions`, and `tests` (or `islTests`) with `name`, `functionName`, `input`, `expected`, and optional per-test `assertOptions`. See [../README.md](../README.md#test-command) for a short reference and [../docs/ext/unit-testing/yaml-tests.md](../../docs/ext/unit-testing/yaml-tests.md) for the full format and assertOptions reference.
diff --git a/isl-cmd/examples/mocks/sample-mocks.yaml b/isl-cmd/examples/mocks/sample-mocks.yaml
new file mode 100644
index 0000000..7cb14db
--- /dev/null
+++ b/isl-cmd/examples/mocks/sample-mocks.yaml
@@ -0,0 +1,73 @@
+# Sample mock file for use with @.Mock.Load("mocks/sample-mocks.yaml")
+# Path is relative to the directory of the ISL file that loads it.
+#
+# Use in tests or scripts:
+# @.Mock.Load("mocks/sample-mocks.yaml")
+#
+# Format: func and optional annotation arrays; each entry has name, result (or isl), and optional params.
+# Use "result" (or "return") for a static return value, or "isl" to define an ISL function that runs as the mock.
+#
+# Pipe modifiers: to mock "| name" (e.g. $x | mask2), use func with name "Modifier.[Name]" (e.g. Modifier.mask2).
+# Annotations: to mock "@name" on a function (e.g. @mask2 fun foo()), use the annotation array with name "name".
+
+func:
+ # Simple mock: same return every time
+ - name: "Api.Call"
+ result: { status: 200, body: "ok" }
+
+ # ISL mock: compile and run an ISL function in context (receives call parameters as function arguments)
+ - name: "mask"
+ isl: |
+ fun mask( $value ) {
+ return `xxxxxx$value`;
+ }
+
+ # Pipe modifier mock: "Modifier.[Name]" makes "| name" work (e.g. $x | mask2)
+ - name: "Modifier.mask2"
+ isl: |
+ fun mask2( $value ) {
+ return `xxxxxx$value`;
+ }
+
+ # Parameter-based mock: return only when arguments match (1 param)
+ - name: "Service.GetUser"
+ result: { id: 1, name: "Alice", role: "admin" }
+ params: ["user-1"]
+
+ # Two-parameter mock: match (a, b) and return computed result
+ - name: "Math.Compute"
+ result: 42
+ params: [10, 32]
+ - name: "Math.Compute"
+ result: 100
+ params: [50, 50]
+
+ # Three-parameter mock: e.g. lookup(table, key, id)
+ - name: "Lookup.Find"
+ result: { id: "u-1", name: "Alice", role: "admin" }
+ params: ["users", "id", "u-1"]
+ - name: "Lookup.Find"
+ result: null
+ params: ["users", "id", "unknown"]
+
+ # Indexed mocks: different return per call (#1 first call, #2 second, etc.)
+ - name: "Data.GetItems#1"
+ result: [ { id: 1 }, { id: 2 }, { id: 3 } ]
+ - name: "Data.GetItems#2"
+ result: [ { id: 4 }, { id: 5 } ]
+ - name: "Data.GetItems#3"
+ result: []
+
+ # Scalar and null returns
+ - name: "Config.GetLimit"
+ result: 100
+ - name: "Feature.IsEnabled"
+ result: true
+
+# Annotation mocks: for @name on a function (e.g. @mask2 fun foo() { ... })
+# These mock the annotation decorator; use "Modifier.name" in func to mock the pipe "| name".
+annotation:
+ - name: "mask2"
+ result: "masked"
+ # - name: "cache#1"
+ # result: "cached-value"
diff --git a/isl-cmd/examples/run-examples.bat b/isl-cmd/examples/run-examples.bat
index 1ee617e..7551770 100644
--- a/isl-cmd/examples/run-examples.bat
+++ b/isl-cmd/examples/run-examples.bat
@@ -1,16 +1,26 @@
@echo off
REM Example usage scripts for ISL CLI on Windows
+cd /d "%~dp0"
+
echo === ISL CLI Examples ===
echo.
-REM Check if JAR exists
-set JAR=..\build\libs\isl-2.4.20-SNAPSHOT.jar
+REM Read version from gradle.properties
+for /f "tokens=1,2 delims==" %%a in (..\..\gradle.properties) do (
+ if "%%a"=="version" set VERSION=%%b
+)
+
+REM Shadow JAR has classifier -all
+set JAR=..\build\libs\isl-%VERSION%-all.jar
+if not exist "%JAR%" set JAR=..\build\libs\isl-%VERSION%.jar
if not exist "%JAR%" (
echo Building ISL CLI...
cd ..
call gradlew.bat shadowJar
cd examples
+ set JAR=..\build\libs\isl-%VERSION%-all.jar
+ if not exist "%JAR%" set JAR=..\build\libs\isl-%VERSION%.jar
)
set ISL=java -jar %JAR%
@@ -43,5 +53,11 @@ echo 5. Show Info
echo Command: isl info
%ISL% info
echo.
+echo.
+
+echo 6. Run Tests (ISL + YAML suites)
+echo Command: isl test .
+%ISL% test .
+echo.
diff --git a/isl-cmd/examples/run-examples.sh b/isl-cmd/examples/run-examples.sh
index fd8ed99..cc60274 100644
--- a/isl-cmd/examples/run-examples.sh
+++ b/isl-cmd/examples/run-examples.sh
@@ -8,13 +8,21 @@ echo ""
# Ensure we're in the right directory
cd "$(dirname "$0")"
-# Check if JAR exists
-JAR="../build/libs/isl-2.4.20-SNAPSHOT.jar"
+# Read version from gradle.properties
+VERSION=$(grep "^version=" "../../gradle.properties" | cut -d'=' -f2 | tr -d '\r')
+
+# Shadow JAR has classifier -all
+JAR="../build/libs/isl-$VERSION-all.jar"
+if [ ! -f "$JAR" ]; then
+ JAR="../build/libs/isl-$VERSION.jar"
+fi
if [ ! -f "$JAR" ]; then
echo "Building ISL CLI..."
cd ..
./gradlew shadowJar
cd examples
+ JAR="../build/libs/isl-$VERSION-all.jar"
+ [ ! -f "$JAR" ] && JAR="../build/libs/isl-$VERSION.jar"
fi
ISL="java -jar $JAR"
@@ -47,5 +55,11 @@ echo "5. Show Info"
echo " Command: isl info"
$ISL info
echo ""
+echo ""
+
+echo "6. Run Tests (ISL + YAML suites)"
+echo " Command: isl test ."
+$ISL test .
+echo ""
diff --git a/isl-cmd/examples/tests/calculator.isl b/isl-cmd/examples/tests/calculator.isl
new file mode 100644
index 0000000..508a926
--- /dev/null
+++ b/isl-cmd/examples/tests/calculator.isl
@@ -0,0 +1,18 @@
+// Sample ISL module for unit testing (no mocks required).
+// Run with: isl test . (discovers calculator.tests.yaml)
+
+fun add($a, $b) {
+ return {{ $a + $b }};
+}
+
+fun double($x) {
+ return {{ $x * 2 }};
+}
+
+fun greet($name) {
+ return `Hello, ${ $name }`;
+}
+
+fun echo($value) {
+ return $value;
+}
diff --git a/isl-cmd/examples/tests/calculator.tests.yaml b/isl-cmd/examples/tests/calculator.tests.yaml
new file mode 100644
index 0000000..1c79bb8
--- /dev/null
+++ b/isl-cmd/examples/tests/calculator.tests.yaml
@@ -0,0 +1,43 @@
+# YAML-driven unit tests for calculator.isl
+# Run: isl test . or isl test calculator.tests.yaml
+
+category: calculator
+setup:
+ islSource: calculator.isl
+
+tests:
+ - name: add two numbers
+ functionName: add
+ input:
+ a: 2
+ b: 3
+ expected: 5
+
+ - name: add negatives
+ functionName: add
+ input:
+ a: -10
+ b: 5
+ expected: -5
+
+ - name: double a number
+ functionName: double
+ input: 7
+ expected: 14
+
+ - name: double zero
+ functionName: double
+ input: 0
+ expected: 0
+
+ - name: greet returns message
+ functionName: greet
+ input: "World"
+ expected: "Hello, World"
+
+ - name: echo returns input
+ functionName: echo
+ input: { foo: "bar", n: 42 }
+ expected:
+ foo: "bar"
+ n: 42
diff --git a/isl-cmd/examples/tests/sample.isl b/isl-cmd/examples/tests/sample.isl
new file mode 100644
index 0000000..6fd65ec
--- /dev/null
+++ b/isl-cmd/examples/tests/sample.isl
@@ -0,0 +1,19 @@
+// Annotation-based tests (@setup and @test) – discovered by: isl test .
+// Uses @.Mock.Load for mocks; assertions via @.Assert.
+
+@setup
+fun setupTests() {
+ @.Mock.Load("../mocks/sample-mocks.yaml")
+}
+
+@test
+fun testApiCallMock() {
+ $r : @.Api.Call("https://example.com")
+ @.Assert.Equal({ status: 200, body: "ok" }, $r)
+}
+
+@test
+fun testConfigLimit() {
+ $limit : @.Config.GetLimit()
+ @.Assert.Equal(100, $limit)
+}
diff --git a/isl-cmd/examples/tests/service.isl b/isl-cmd/examples/tests/service.isl
new file mode 100644
index 0000000..73002c8
--- /dev/null
+++ b/isl-cmd/examples/tests/service.isl
@@ -0,0 +1,35 @@
+// Sample ISL module that calls an external "Api.Call" (mocked in tests).
+// Run with: isl test service.tests.yaml
+
+fun run($url) {
+ $r : @.Api.Call($url);
+ return $r;
+}
+
+fun getConfigLimit() {
+ $limit : @.Config.GetLimit();
+ return $limit;
+}
+
+fun getItems() {
+ $first : @.Data.GetItems();
+ $second : @.Data.GetItems();
+ $third : @.Data.GetItems();
+ return {
+ first: $first,
+ second: $second,
+ third: $third
+ };
+}
+
+// Two-parameter mocked function
+fun computeSum($a, $b) {
+ $r : @.Math.Compute($a, $b);
+ return $r;
+}
+
+// Three-parameter mocked function
+fun lookup($table, $key, $id) {
+ $r : @.Lookup.Find($table, $key, $id);
+ return $r;
+}
diff --git a/isl-cmd/examples/tests/service.tests.yaml b/isl-cmd/examples/tests/service.tests.yaml
new file mode 100644
index 0000000..66b69b4
--- /dev/null
+++ b/isl-cmd/examples/tests/service.tests.yaml
@@ -0,0 +1,65 @@
+# YAML-driven unit tests for service.isl (uses mocks)
+# Run: isl test . or isl test service.tests.yaml
+#
+# mockSource is loaded first; mocks below override or add.
+
+category: service
+setup:
+ islSource: service.isl
+ mockSource: ../mocks/sample-mocks.yaml
+ mocks:
+ func:
+ - name: "Api.Call"
+ return: { status: 200, body: "overridden" }
+
+tests:
+ - name: run returns mocked Api.Call response
+ functionName: run
+ input: "https://example.com"
+ expected:
+ status: 200
+ body: "overridden"
+
+ - name: getConfigLimit returns mocked scalar
+ functionName: getConfigLimit
+ expected: 100
+
+ - name: getItems returns indexed mock results
+ functionName: getItems
+ expected:
+ first: [ { id: 1 }, { id: 2 }, { id: 3 } ]
+ second: [ { id: 4 }, { id: 5 } ]
+ third: []
+
+ - name: computeSum two params match first mock (10, 32)
+ functionName: computeSum
+ input:
+ a: 10
+ b: 32
+ expected: 42
+
+ - name: computeSum two params match second mock (50, 50)
+ functionName: computeSum
+ input:
+ a: 50
+ b: 50
+ expected: 100
+
+ - name: lookup three params returns user
+ functionName: lookup
+ input:
+ table: "users"
+ key: "id"
+ id: "u-1"
+ expected:
+ id: "u-1"
+ name: "Alice"
+ role: "admin"
+
+ - name: lookup three params returns null for unknown
+ functionName: lookup
+ input:
+ table: "users"
+ key: "id"
+ id: "unknown"
+ expected: null
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt
index 96ad028..478cfa1 100644
--- a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslCommandLine.kt
@@ -18,7 +18,8 @@ import kotlin.system.exitProcess
subcommands = [
TransformCommand::class,
ValidateCommand::class,
- InfoCommand::class
+ InfoCommand::class,
+ TestCommand::class
]
)
class IslCommandLine : Runnable {
@@ -28,6 +29,7 @@ class IslCommandLine : Runnable {
}
fun main(args: Array) {
+ Transformer.getIslInfo() // Log ISL and Jackson versions at startup
val exitCode = CommandLine(IslCommandLine()).execute(*args)
exitProcess(exitCode)
}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslModuleResolver.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslModuleResolver.kt
new file mode 100644
index 0000000..d71fca1
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/IslModuleResolver.kt
@@ -0,0 +1,109 @@
+package com.intuit.isl.cmd
+
+import com.intuit.isl.runtime.FileInfo
+import com.intuit.isl.runtime.TransformCompilationException
+import com.intuit.isl.runtime.TransformPackageBuilder
+import java.nio.file.Path
+import java.nio.file.Paths
+
+/**
+ * Shared module resolution for ISL commands (transform, validate, test).
+ * Resolves relative imports (e.g. `import Customer from "../customer.isl"`) consistently
+ * across all commands that compile ISL scripts.
+ *
+ * When [resolvedPaths] is provided, the directory of the **importing** module is taken from
+ * where that module was actually loaded (so e.g. coreUtils.isl is resolved relative to
+ * lib/isl_common_utils/ when imported from bankingUtils.isl in that folder), not relative to basePath.
+ */
+object IslModuleResolver {
+
+ /**
+ * Resolves an import path relative to the importing module's directory.
+ * @param basePath Base path (script directory or search root)
+ * @param fromModule Current module name/path as known by the runtime (e.g. "lib/isl_common_utils/bankingUtils.isl")
+ * @param dependentModule Import path from the script (e.g. "coreUtils.isl" or "../other.isl")
+ * @param resolvedPaths Optional map of module name -> absolute path where that module was loaded.
+ * When provided, fromDir is taken from the path of fromModule if present,
+ * and each resolved dependency is recorded so nested imports resolve correctly.
+ * @return File contents or null if not found
+ */
+ fun resolveExternalModule(
+ basePath: Path,
+ fromModule: String,
+ dependentModule: String,
+ resolvedPaths: MutableMap? = null
+ ): String? {
+ val base = basePath.toAbsolutePath().normalize()
+ val fromDir = when {
+ resolvedPaths != null && resolvedPaths.containsKey(fromModule) -> resolvedPaths[fromModule]!!.parent
+ else -> base.resolve(fromModule).parent ?: base
+ }
+ if (TestRunFlags.shouldShowScriptLogs()) println("[ISL resolve] fromModule=$fromModule, dependentModule=$dependentModule, fromDir=$fromDir")
+ val candidateNames = if (dependentModule.endsWith(".isl", ignoreCase = true)) {
+ listOf(dependentModule)
+ } else {
+ listOf("$dependentModule.isl", "$dependentModule.ISL")
+ }
+ for (name in candidateNames) {
+ val candidatePath = fromDir.resolve(name).normalize().toAbsolutePath()
+ val file = candidatePath.toFile()
+ if (file.exists() && file.isFile) {
+ resolvedPaths?.set(dependentModule, candidatePath)
+ return file.readText()
+ }
+ }
+ val moduleBaseName = if (dependentModule.endsWith(".isl", ignoreCase = true)) {
+ dependentModule.dropLast(4)
+ } else {
+ dependentModule
+ }
+ val found = base.toFile().walkTopDown()
+ .filter { it.isFile && it.extension.equals("isl", true) }
+ .find { it.nameWithoutExtension.equals(moduleBaseName, true) }
+ if (found != null && resolvedPaths != null) resolvedPaths[dependentModule] = found.toPath().toAbsolutePath().normalize()
+ return found?.readText()
+ }
+
+ /**
+ * Creates a findExternalModule that records where each module was loaded so nested imports
+ * resolve relative to the importing file's actual directory.
+ * @param basePath Base path for resolution when a module is not yet in the map
+ * @param resolvedPaths Map to fill: module name -> path where it was loaded. Prime with initial module(s) before use.
+ * @return BiFunction suitable for TransformPackageBuilder
+ */
+ fun createModuleFinder(
+ basePath: Path,
+ resolvedPaths: MutableMap
+ ): java.util.function.BiFunction {
+ return java.util.function.BiFunction { fromModule, dependentModule ->
+ resolveExternalModule(basePath, fromModule, dependentModule, resolvedPaths)
+ ?: throw TransformCompilationException(
+ "Could not find module '$dependentModule' (imported from $fromModule). Searched relative to ${resolvedPaths[fromModule]?.parent ?: basePath.resolve(fromModule).parent}"
+ )
+ }
+ }
+
+ /**
+ * Creates a findExternalModule for TransformPackageBuilder when compiling a single ISL file.
+ * Uses resolution history so nested imports (e.g. lib/foo.isl importing bar.isl) resolve relative to the importing file.
+ */
+ fun buildPackageForSingleFile(scriptFile: java.io.File, scriptContent: String): Pair> {
+ if (TestRunFlags.shouldShowScriptLogs()) println("[ISL load] initial file: ${scriptFile.absolutePath}")
+ val basePath = scriptFile.parentFile?.toPath()?.normalize() ?: Paths.get(".").toAbsolutePath().normalize()
+ val moduleName = scriptFile.name
+ val fileInfo = FileInfo(moduleName, scriptContent)
+ val resolvedPaths = mutableMapOf()
+ resolvedPaths[moduleName] = scriptFile.toPath().toAbsolutePath().normalize()
+ val findExternalModule = createModuleFinder(basePath, resolvedPaths)
+ return fileInfo to findExternalModule
+ }
+
+ /**
+ * Compiles a single ISL file with dependent module resolution.
+ * Returns the TransformPackage for the compiled module.
+ */
+ fun compileSingleFile(scriptFile: java.io.File, scriptContent: String): com.intuit.isl.runtime.TransformPackage {
+ val (fileInfo, findExternalModule) = buildPackageForSingleFile(scriptFile, scriptContent)
+ return TransformPackageBuilder().build(mutableListOf(fileInfo), findExternalModule)
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt
new file mode 100644
index 0000000..43c51f7
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/LogExtensions.kt
@@ -0,0 +1,86 @@
+package com.intuit.isl.cmd
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.intuit.isl.common.FunctionExecuteContext
+import com.intuit.isl.common.IOperationContext
+import com.intuit.isl.utils.ConvertUtils
+import com.intuit.isl.utils.JsonConvert
+
+/**
+ * Log extension functions for ISL scripts when running transforms or tests from the command line.
+ *
+ * Usage in ISL:
+ * @.Log.Info("Processing item", $count)
+ * @.Log.Warn("Unexpected value:", $value)
+ * @.Log.Error("Failed:", $error)
+ * @.Log.Debug("Debug info") // Only outputs when -Ddebug=true
+ */
+object LogExtensions {
+ fun registerExtensions(context: IOperationContext) {
+ context.registerExtensionMethod("Log.Debug", LogExtensions::debug)
+ context.registerExtensionMethod("Log.Info", LogExtensions::info)
+ context.registerExtensionMethod("Log.Warn", LogExtensions::warn)
+ context.registerExtensionMethod("Log.Error", LogExtensions::error)
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun debug(context: FunctionExecuteContext): Any? {
+ if (System.getProperty("debug") != "true") return null
+ val message = formatMessage(context.parameters)
+ val loc = logLocation(context)
+ println("[ISL Log $loc] $message")
+ return null
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun info(context: FunctionExecuteContext): Any? {
+ val message = formatMessage(context.parameters)
+ val loc = logLocation(context)
+ println("[ISL Log $loc] $message")
+ return null
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun warn(context: FunctionExecuteContext): Any? {
+ val message = formatMessage(context.parameters)
+ val loc = logLocation(context)
+ System.err.println("[ISL Log $loc] $message")
+ return null
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun error(context: FunctionExecuteContext): Any? {
+ val message = formatMessage(context.parameters)
+ val loc = logLocation(context)
+ System.err.println("[ISL Log $loc] $message")
+ return null
+ }
+
+ private fun logLocation(context: FunctionExecuteContext): String {
+ val pos = context.command.token.position
+ return "[${pos.file}]:${pos.line}"
+ }
+
+ private fun formatMessage(parameters: Array<*>): String {
+ if (parameters.isEmpty()) return ""
+ return parameters.joinToString(" ") { param ->
+ when (param) {
+ null -> "null"
+ is JsonNode -> param.toPrettyString()
+ else -> ConvertUtils.tryToString(param) ?: param.toString()
+ }
+ }
+ }
+
+ private fun JsonNode.toPrettyString(): String {
+ return try {
+ JsonConvert.mapper
+ .copy()
+ .enable(SerializationFeature.INDENT_OUTPUT)
+ .writeValueAsString(this)
+ } catch (_: Exception) {
+ this.toString()
+ }
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt
new file mode 100644
index 0000000..5407834
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestCommand.kt
@@ -0,0 +1,462 @@
+package com.intuit.isl.cmd
+
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.intuit.isl.runtime.FileInfo
+import com.intuit.isl.runtime.TransformCompilationException
+import com.intuit.isl.test.TransformTestPackageBuilder
+import com.intuit.isl.test.annotations.TestResult
+import com.intuit.isl.test.annotations.TestResultContext
+import picocli.CommandLine.Command
+import picocli.CommandLine.Option
+import picocli.CommandLine.Parameters
+import java.io.File
+import java.nio.file.FileSystems
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.io.path.extension
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.nameWithoutExtension
+import kotlin.system.exitProcess
+
+/**
+ * Command to execute ISL tests.
+ * Discovers and runs:
+ * - .isl files containing @setup or @test annotations
+ * - *.tests.yaml files (YAML-driven unit test suites with setup.islSource, mockSource, mocks, and tests with functionName, input, expected)
+ */
+@Command(
+ name = "test",
+ aliases = ["tests"],
+ description = ["Execute ISL tests from the specified path or current folder. Runs .isl files with @setup/@test and *.tests.yaml suites. Examples: isl test . | isl test tests/ | isl test calculator.tests.yaml | isl test . -f add"]
+)
+class TestCommand : Runnable {
+
+ @Parameters(
+ index = "0",
+ arity = "0..1",
+ description = ["Path to search for tests: directory, single file, or glob. Examples: . (current dir), tests/, calculator.tests.yaml. Default: current directory"]
+ )
+ var path: File? = null
+
+ @Option(
+ names = ["--glob"],
+ description = ["Glob for .isl files when path is a directory (e.g. **/*.isl). YAML suites (*.tests.yaml) use **/*.tests.yaml when not set"]
+ )
+ var globPattern: String? = null
+
+ @Option(
+ names = ["-o", "--output"],
+ description = ["Write results to a JSON file for parsing by other tools"]
+ )
+ var outputFile: File? = null
+
+ @Option(
+ names = ["-f", "--function"],
+ description = ["Run only the specified test function(s). Can be specified multiple times. Use 'file:function' to target a specific file (e.g. sample.isl:test_customer)"]
+ )
+ var functions: Array = emptyArray()
+
+ @Option(
+ names = ["-v", "--verbose"],
+ description = ["Show detailed logs (search, loading, mocks, per-test progress). Without this, only test name, result, and a summary are shown"]
+ )
+ var verbose: Boolean = false
+
+ @Option(
+ names = ["--report"],
+ arity = "0..1",
+ paramLabel = "FILE",
+ description = ["Write a Markdown test report to FILE. FILE is optional: if given, the report is written there (can be used with or without -o/--output). Summary at top, detailed results below. Example: --report test-report.md"]
+ )
+ var reportFile: File? = null
+
+ override fun run() {
+ TestRunFlags.setTestVerbose(verbose)
+ try {
+ val basePath = (path?.absoluteFile ?: File(System.getProperty("user.dir"))).toPath().normalize()
+ val searchBase = if (basePath.toFile().isDirectory) basePath else basePath.parent
+ if (verbose) {
+ when {
+ basePath.toFile().isFile -> println("[ISL Search] Searching: ${basePath.toAbsolutePath()}")
+ else -> {
+ val islGlob = globPattern ?: "**/*.isl"
+ println("[ISL Search] Searching: ${basePath.toAbsolutePath()} (ISL: $islGlob, YAML: **/*.tests.yaml)")
+ }
+ }
+ }
+ val testFiles = discoverTestFiles(basePath)
+ val yamlSuites = discoverYamlTestSuites(basePath)
+ if (testFiles.isEmpty() && yamlSuites.isEmpty()) {
+ System.err.println(red("[ISL Error] No test files found (looking for .isl with @setup/@test, or *.tests.yaml). Try: isl test or isl test path/to/suite.tests.yaml"))
+ exitProcess(1)
+ }
+ val result = TestResultContext()
+ val contextCustomizers: List<(com.intuit.isl.common.IOperationContext) -> Unit> = listOf(
+ { ctx -> LogExtensions.registerExtensions(ctx) },
+ { ctx -> TestExtensions.registerExtensions(ctx) }
+ )
+ val functionFilter = functions.map { it.trim() }.filter { it.isNotEmpty() }.toSet()
+
+ if (testFiles.isNotEmpty()) {
+ if (verbose) println("[ISL Loading] Found ${testFiles.size} ISL test file(s)")
+ val fileInfos = testFiles.map { (filePath, content) ->
+ val moduleName = searchBase.relativize(filePath).toString().replace("\\", "/")
+ FileInfo(moduleName, content)
+ }.toMutableList()
+ val findExternalModule = createModuleResolver(testFiles, searchBase)
+ try {
+ @Suppress("UNCHECKED_CAST")
+ val testPackage = TransformTestPackageBuilder().build(
+ fileInfos,
+ findExternalModule as java.util.function.BiFunction,
+ searchBase,
+ contextCustomizers
+ )
+ if (functionFilter.isEmpty()) {
+ testPackage.runAllTests(result)
+ } else {
+ testPackage.runFilteredTests(result) { file, func ->
+ functionFilter.any { filter ->
+ when {
+ filter.contains(":") -> {
+ val parts = filter.split(":", limit = 2)
+ val fileMatch = parts[0].equals(file, true) ||
+ parts[0].equals(file.removeSuffix(".isl"), true)
+ fileMatch && parts[1].equals(func, true)
+ }
+ else -> filter.equals(func, true)
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ result.testResults.addAll(createErrorResult(e, fileInfos).testResults)
+ }
+ }
+
+ if (yamlSuites.isNotEmpty()) {
+ if (verbose) println("[ISL Loading] Found ${yamlSuites.size} YAML test suite(s)")
+ for (yamlPath in yamlSuites) {
+ val suiteBase = if (yamlPath.toFile().isFile) yamlPath.parent else yamlPath
+ YamlUnitTestRunner.runSuite(yamlPath, suiteBase, result, contextCustomizers, functionFilter, verbose)
+ }
+ }
+
+ if (result.testResults.isEmpty()) {
+ System.err.println(red("[ISL Error] No tests ran. Check path and --function filter."))
+ exitProcess(1)
+ }
+ // Always print results to console first; file output is in addition to, not instead of, logs
+ reportResults(result, verbose)
+ outputFile?.let { writeResultsToJson(result, it) }
+ reportFile?.let { writeReportMarkdown(result, it) }
+ val failedCount = result.testResults.count { !it.success }
+ if (failedCount > 0) {
+ exitProcess(1)
+ }
+ } catch (e: Exception) {
+ System.err.println(red("[ISL Error] Error: ${e.message}"))
+ if (System.getProperty("debug") == "true") {
+ e.printStackTrace()
+ }
+ exitProcess(1)
+ } finally {
+ TestRunFlags.clear()
+ }
+ }
+
+ private fun discoverTestFiles(basePath: Path): List> {
+ val islFiles = when {
+ basePath.toFile().isFile -> {
+ if (basePath.toString().endsWith(".isl", true)) listOf(basePath) else emptyList()
+ }
+ basePath.toFile().isDirectory -> {
+ val pattern = globPattern ?: "**/*.isl"
+ val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern")
+ Files.walk(basePath)
+ .use { stream ->
+ stream
+ .filter { it.isRegularFile() && it.extension.equals("isl", true) }
+ .filter { path ->
+ val relative = basePath.relativize(path)
+ val normalized = relative.toString().replace("\\", "/")
+ globPattern == null || matcher.matches(FileSystems.getDefault().getPath(normalized))
+ }
+ .toList()
+ }
+ }
+ else -> emptyList()
+ }
+ return islFiles
+ .mapNotNull { path ->
+ val content = path.toFile().readText()
+ if (content.contains("@setup") || content.contains("@test")) {
+ path to content
+ } else null
+ }
+ }
+
+ private fun discoverYamlTestSuites(basePath: Path): List {
+ return when {
+ basePath.toFile().isFile -> {
+ if (basePath.toString().endsWith(".tests.yaml", true) || basePath.toString().endsWith(".tests.yml", true)) {
+ listOf(basePath)
+ } else emptyList()
+ }
+ basePath.toFile().isDirectory -> {
+ val pattern = globPattern ?: "**/*.tests.yaml"
+ val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern")
+ Files.walk(basePath)
+ .use { stream ->
+ stream
+ .filter { it.isRegularFile() }
+ .filter { path ->
+ val ext = path.extension.lowercase()
+ (ext == "yaml" || ext == "yml") && path.nameWithoutExtension.endsWith(".tests", ignoreCase = true)
+ }
+ .filter { path ->
+ val relative = basePath.relativize(path)
+ val normalized = relative.toString().replace("\\", "/")
+ globPattern == null || matcher.matches(FileSystems.getDefault().getPath(normalized))
+ }
+ .toList()
+ }
+ }
+ else -> emptyList()
+ }
+ }
+
+ private fun createModuleResolver(testFiles: List>, searchBase: Path): java.util.function.BiFunction {
+ val fileByModuleName = testFiles.associate { (filePath, content) ->
+ val moduleName = searchBase.relativize(filePath).toString().replace("\\", "/").removeSuffix(".isl")
+ moduleName to content
+ }
+ val fileByFullName = testFiles.associate { (filePath, content) ->
+ searchBase.relativize(filePath).toString().replace("\\", "/") to content
+ }
+ val resolvedPaths = mutableMapOf()
+ testFiles.forEach { (filePath, _) ->
+ val fullName = searchBase.relativize(filePath).toString().replace("\\", "/")
+ resolvedPaths[fullName] = filePath.toAbsolutePath().normalize()
+ }
+ return java.util.function.BiFunction { fromModule: String, dependentModule: String ->
+ fileByFullName[dependentModule]
+ ?: fileByModuleName[dependentModule]
+ ?: IslModuleResolver.resolveExternalModule(searchBase, fromModule, dependentModule, resolvedPaths)
+ ?: throw TransformCompilationException(
+ "Could not find module '$dependentModule' (imported from $fromModule). Searched relative to ${resolvedPaths[fromModule]?.parent ?: searchBase.resolve(fromModule).parent}"
+ )
+ }
+ }
+
+ private fun reportResults(result: TestResultContext, verbose: Boolean) {
+ val passed = result.testResults.count { it.success }
+ val failed = result.testResults.count { !it.success }
+ val total = result.testResults.size
+
+ //if (verbose) {
+ val byGroup = result.testResults.groupBy { it.testGroup ?: it.testFile }
+ byGroup.forEach { (group, tests) ->
+ println("[ISL Result] $group")
+ tests.forEach { tr ->
+ val displayName = if (tr.testName != tr.functionName) "${tr.testName} (${tr.functionName})" else tr.testName
+ if (tr.success) {
+ println("[ISL Result] ${green("[PASS]")} $displayName")
+ } else {
+ println("[ISL Result] ${red("[FAIL]")} $displayName")
+ tr.message?.let { println("[ISL Result] ${red(it)}") }
+ tr.errorPosition?.let { pos ->
+ val loc = "${pos.file}:${pos.line}:${pos.column}"
+ println("[ISL Result] ${red("at $loc")}")
+ }
+ }
+ }
+ }
+ println("[ISL Result] ---")
+ val resultsLine = "Results: $passed passed, $failed failed, $total total"
+ println(if (failed > 0) red("[ISL Result] $resultsLine") else "[ISL Result] $resultsLine")
+ //} else {
+
+ // Nice summary
+ // printSummary(passed, failed, total)
+ //}
+ }
+
+ private fun printSummary(passed: Int, failed: Int, total: Int) {
+ val summaryText = if (failed == 0) {
+ "All tests passed ($total total)"
+ } else {
+ "$failed failed, $passed passed ($total total)"
+ }
+ val summary = if (failed == 0) {
+ "${green("All tests passed")} ($total total)"
+ } else {
+ "${red("$failed failed")}, $passed passed ($total total)"
+ }
+ val contentWidth = summaryText.length.coerceAtLeast(28)
+ val line = "─".repeat(contentWidth + 4)
+ val padding = " ".repeat((contentWidth - summaryText.length).coerceAtLeast(0))
+ println()
+ println("┌$line┐")
+ println("│ $summary$padding │")
+ println("└$line┘")
+ }
+
+ /** Use ANSI color only when stdout is a TTY (e.g. terminal). When piped (e.g. from VS Code Test Explorer), output is plain text. */
+ private fun useColor(): Boolean = System.console() != null
+
+ private fun green(text: String) = if (useColor()) "\u001B[32m$text\u001B[0m" else text
+ private fun red(text: String) = if (useColor()) "\u001B[31m$text\u001B[0m" else text
+
+ private fun createErrorResult(e: Exception, fileInfos: List): TestResultContext {
+ val (message, position) = when (e) {
+ is TransformCompilationException -> e.message to e.position
+ is com.intuit.isl.runtime.TransformException -> e.message to e.position
+ is com.intuit.isl.runtime.IslException -> e.message to e.position
+ else -> e.message to null
+ }
+ val firstFile = fileInfos.firstOrNull()?.name ?: "unknown"
+ val errorResult = TestResult(
+ testFile = position?.file ?: firstFile,
+ functionName = "compilation",
+ testName = "compilation",
+ testGroup = firstFile,
+ success = false,
+ message = message ?: e.toString(),
+ errorPosition = position
+ )
+ return TestResultContext(mutableListOf(errorResult))
+ }
+
+ private fun writeResultsToJson(result: TestResultContext, file: File) {
+ val passed = result.testResults.count { it.success }
+ val failed = result.testResults.count { !it.success }
+ val results = result.testResults.map { tr ->
+ mapOf(
+ "testFile" to tr.testFile,
+ "functionName" to tr.functionName,
+ "testName" to tr.testName,
+ "testGroup" to tr.testGroup,
+ "success" to tr.success,
+ "message" to tr.message,
+ "errorPosition" to (tr.errorPosition?.let { pos ->
+ mapOf(
+ "file" to pos.file,
+ "line" to pos.line,
+ "column" to pos.column
+ )
+ })
+ )
+ }
+ val output = mapOf(
+ "passed" to passed,
+ "failed" to failed,
+ "total" to result.testResults.size,
+ "success" to (failed == 0),
+ "results" to results
+ )
+ val mapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)
+ file.writeText(mapper.writeValueAsString(output))
+ println("[ISL Output] Results written to: ${file.absolutePath}")
+ }
+
+ private fun writeReportMarkdown(result: TestResultContext, reportFile: File) {
+ val passed = result.testResults.count { it.success }
+ val failed = result.testResults.count { !it.success }
+ val total = result.testResults.size
+ val success = failed == 0
+
+ val md = buildString {
+ // Title and summary at top
+ appendLine("# ISL Test Report")
+ appendLine()
+ appendLine("**Generated:** ${java.time.Instant.now().atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME)}")
+ appendLine()
+ appendLine("## Summary")
+ appendLine()
+ appendLine("| | Count |")
+ appendLine("|---|------|")
+ appendLine("| **Total** | $total |")
+ appendLine("| **Passed** | $passed |")
+ appendLine("| **Failed** | $failed |")
+ appendLine("| **Status** | ${if (success) "✅ All passed" else "❌ $failed failed"} |")
+ appendLine()
+ appendLine("---")
+ appendLine()
+ appendLine("## Detailed Results")
+ appendLine()
+
+ val byGroup = result.testResults.groupBy { it.testGroup ?: it.testFile }
+ for ((group, tests) in byGroup) {
+ appendLine("### $group")
+ appendLine()
+ for (tr in tests) {
+ val fileLabel = "`${tr.testFile.replace("`", "\\`")}`"
+ val displayName = if (tr.testName != tr.functionName) "${tr.testName} (`${tr.functionName}`)" else tr.testName
+ val line = "$fileLabel — $displayName"
+ if (tr.success) {
+ appendLine("- ✅ **$line**")
+ } else {
+ appendLine("- ❌ **$line**")
+ val hasExpectedActual = tr.expectedJson != null && tr.actualJson != null
+ tr.message?.let { msg ->
+ appendLine(" - *${escapeMarkdownInline(msg.lines().first().trim())}*")
+ if (hasExpectedActual) {
+ appendLine()
+ appendLine(" **Expected:**")
+ appendLine(" ```json")
+ prettyJson(tr.expectedJson!!).lines().forEach { appendLine(" $it") }
+ appendLine(" ```")
+ appendLine()
+ appendLine(" **Actual:**")
+ appendLine(" ```json")
+ prettyJson(tr.actualJson!!).lines().forEach { appendLine(" $it") }
+ appendLine(" ```")
+ val diffs = tr.comparisonDiffs
+ if (!diffs.isNullOrEmpty()) {
+ appendLine()
+ appendLine(" **Differences:**")
+ for (d in diffs) {
+ appendLine()
+ appendLine(" **Expected:**")
+ appendLine(" ```")
+ appendLine(" ${d.path} = ${d.expectedValue}")
+ appendLine(" ```")
+ appendLine(" **Actual:**")
+ appendLine(" ```")
+ appendLine(" ${d.path} = ${d.actualValue}")
+ appendLine(" ```")
+ }
+ }
+ } else if (msg.lines().size > 1) {
+ appendLine(" ```")
+ msg.lines().take(20).forEach { appendLine(it) }
+ if (msg.lines().size > 20) appendLine(" ...")
+ appendLine(" ```")
+ }
+ }
+ tr.errorPosition?.let { pos ->
+ appendLine(" - `${pos.file}:${pos.line}:${pos.column}`")
+ }
+ }
+ }
+ appendLine()
+ }
+ }
+ reportFile.writeText(md)
+ println("[ISL Output] Report written to: ${reportFile.absolutePath}")
+ }
+
+ private fun escapeMarkdownInline(s: String): String =
+ s.replace("\\", "\\\\").replace("`", "\\`").replace("*", "\\*").replace("_", "\\_")
+
+ private fun prettyJson(json: String): String {
+ return try {
+ val tree = jacksonObjectMapper().readTree(json)
+ jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).writeValueAsString(tree)
+ } catch (_: Exception) {
+ json
+ }
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestExtensions.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestExtensions.kt
new file mode 100644
index 0000000..4b691be
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestExtensions.kt
@@ -0,0 +1,30 @@
+package com.intuit.isl.cmd
+
+import com.intuit.isl.common.FunctionExecuteContext
+import com.intuit.isl.common.IOperationContext
+
+/**
+ * Exception thrown by @.Test.Exit(...) to signal an early test exit with a result.
+ * The YAML test runner catches this and uses [result] as the test result for comparison with expected.
+ */
+class TestExitException(val result: Any?) : RuntimeException("Test.Exit with result")
+
+/**
+ * Test extension functions for ISL scripts when running tests from the command line.
+ *
+ * Usage in ISL:
+ * @.Test.Exit() // exit with null result
+ * @.Test.Exit($value) // exit with $value as the test result
+ *
+ * When the runner catches this, the result is compared with the test's expected value.
+ */
+object TestExtensions {
+ fun registerExtensions(context: IOperationContext) {
+ context.registerExtensionMethod("Test.Exit", TestExtensions::exit)
+ }
+
+ private fun exit(context: FunctionExecuteContext): Nothing {
+ val result = context.firstParameter
+ throw TestExitException(result)
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestRunFlags.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestRunFlags.kt
new file mode 100644
index 0000000..229130d
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TestRunFlags.kt
@@ -0,0 +1,26 @@
+package com.intuit.isl.cmd
+
+/**
+ * Thread-local flags for the current test run. Set by [TestCommand] so that
+ * [LogExtensions], [IslModuleResolver], and [YamlUnitTestRunner] can reduce
+ * output when -verbose is not passed.
+ */
+object TestRunFlags {
+ private val verbose = ThreadLocal()
+
+ /** Call at start of test run: pass true for -verbose, false for quiet. */
+ fun setTestVerbose(verbose: Boolean) {
+ this.verbose.set(verbose)
+ }
+
+ /** True when test run is in verbose mode. */
+ fun isVerbose(): Boolean = verbose.get() == true
+
+ /** Show script logs (@.Log.Info etc.) only when not in a quiet test run. */
+ fun shouldShowScriptLogs(): Boolean = verbose.get() != false
+
+ /** Call when test run finishes (e.g. in finally). */
+ fun clear() {
+ verbose.remove()
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt
index a645bdd..8914990 100644
--- a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/TransformCommand.kt
@@ -7,7 +7,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.intuit.isl.common.OperationContext
-import com.intuit.isl.runtime.TransformCompiler
import com.intuit.isl.utils.JsonConvert
import kotlinx.coroutines.runBlocking
import picocli.CommandLine.Command
@@ -58,7 +57,7 @@ class TransformCommand : Runnable {
@Option(
names = ["-f", "--format"],
- description = ["Output format: json, yaml, pretty-json (default: json)"]
+ description = ["Output format: json, yaml, pretty-json (default: json, or inferred from -o file extension)"]
)
var format: String = "json"
@@ -76,6 +75,15 @@ class TransformCommand : Runnable {
override fun run() {
try {
+ // Infer output format from output file extension when not explicitly set
+ if (outputFile != null && format == "json") {
+ when (outputFile!!.extension.lowercase()) {
+ "yaml", "yml" -> format = "yaml"
+ "json" -> { /* keep json */ }
+ else -> { /* keep json */ }
+ }
+ }
+
// Validate script file
if (!scriptFile.exists()) {
System.err.println("Error: Script file not found: ${scriptFile.absolutePath}")
@@ -126,16 +134,24 @@ class TransformCommand : Runnable {
variables["input"] = inputData
}
- // Execute transformation
- val compiler = TransformCompiler()
- val transformer = compiler.compileIsl("script", scriptContent)
+ // Add $context with input file info when -i was used
+ if (inputFile != null) {
+ variables["context"] = mapOf("inputFileName" to inputFile!!.name)
+ }
- // Create operation context with variables
+ // Execute transformation using shared module resolution (supports relative imports like ../customer.isl)
+ val transformPackage = IslModuleResolver.compileSingleFile(scriptFile, scriptContent)
+ val transformer = transformPackage.getModule(scriptFile.name)
+ ?: throw IllegalStateException("Compiled module '${scriptFile.name}' not found in package")
+
+ // Create operation context with variables and CLI extensions (e.g. Log)
val context = OperationContext()
+ LogExtensions.registerExtensions(context)
variables.forEach { (key, value) ->
val varName = if (key.startsWith("$")) key else "$$key"
- val varValue = JsonConvert.convert(value);
- println("Setting variable " + varName + " to " + varValue );
+ val varValue = JsonConvert.convert(value)
+ val valuePreview = varValue.toString().let { if (it.length > 10) it.take(10) + "..." else it }
+ println("Setting variable $varName to $valuePreview")
context.setVariable(varName, varValue)
}
@@ -148,7 +164,7 @@ class TransformCommand : Runnable {
if (outputFile != null) {
outputFile!!.writeText(output)
- println("Output written to: ${outputFile!!.absolutePath}")
+ // When -o/--output is set, result goes only to the file (no console output)
} else {
println(output)
}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/ValidateCommand.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/ValidateCommand.kt
index 3747dc6..835918f 100644
--- a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/ValidateCommand.kt
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/ValidateCommand.kt
@@ -1,49 +1,49 @@
package com.intuit.isl.cmd
-import com.intuit.isl.common.OperationContext
-import com.intuit.isl.runtime.TransformCompiler
-import kotlinx.coroutines.runBlocking
import picocli.CommandLine.Command
import picocli.CommandLine.Parameters
import java.io.File
import kotlin.system.exitProcess
/**
- * Command to validate ISL scripts without executing them
+ * Command to validate ISL scripts without executing them.
+ * Compiles the script and lists loaded files and detected functions.
+ * Uses the same module resolution as transform and test commands (supports relative imports).
*/
@Command(
name = "validate",
description = ["Validate an ISL script without executing it"]
)
class ValidateCommand : Runnable {
-
+
@Parameters(
index = "0",
description = ["ISL script file to validate"]
)
lateinit var scriptFile: File
-
+
override fun run() {
try {
if (!scriptFile.exists()) {
System.err.println("Error: Script file not found: ${scriptFile.absolutePath}")
exitProcess(1)
}
-
+
val scriptContent = scriptFile.readText()
- val compiler = TransformCompiler()
-
- // Try to parse and compile the script
- val transformer = compiler.compileIsl(scriptFile.name, scriptContent)
-
- // Try to execute with empty params to validate
- val context = OperationContext()
- runBlocking {
- transformer.runTransformAsync("run", context)
- }
-
+ val transformPackage = IslModuleResolver.compileSingleFile(scriptFile, scriptContent)
+
println("> Script is valid: ${scriptFile.name}")
-
+ println(" Files loaded:")
+ for (moduleName in transformPackage.modules) {
+ println(" $moduleName")
+ }
+ println(" Functions by module:")
+ for (moduleName in transformPackage.modules) {
+ val transformer = transformPackage.getModule(moduleName) ?: continue
+ val functionNames = transformer.module.functions.map { it.name }.sorted()
+ println(" $moduleName: ${functionNames.joinToString(", ").ifEmpty { "(none)" }}")
+ }
+
} catch (e: Exception) {
System.err.println("✗ Validation failed: ${e.message}")
if (System.getProperty("debug") == "true") {
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestRunner.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestRunner.kt
new file mode 100644
index 0000000..6e3aa1f
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestRunner.kt
@@ -0,0 +1,415 @@
+package com.intuit.isl.cmd
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.node.ObjectNode
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import com.fasterxml.jackson.module.kotlin.KotlinFeature
+import com.fasterxml.jackson.module.kotlin.kotlinModule
+import com.fasterxml.jackson.module.kotlin.readValue
+import com.intuit.isl.runtime.FileInfo
+import com.intuit.isl.runtime.TransformCompilationException
+import com.intuit.isl.runtime.TransformException
+import com.intuit.isl.runtime.TransformPackage
+import com.intuit.isl.runtime.TransformPackageBuilder
+import com.intuit.isl.test.TestOperationContext
+import com.intuit.isl.test.annotations.ComparisonDiff
+import com.intuit.isl.test.annotations.TestResult
+import com.intuit.isl.test.annotations.TestResultContext
+import com.intuit.isl.test.mocks.MockFunction
+import com.intuit.isl.utils.JsonConvert
+import java.nio.file.Path
+import kotlin.io.path.extension
+import kotlin.io.path.nameWithoutExtension
+
+/** Empty = run all tests; non-empty = run only tests whose functionName (or "suiteFile:functionName") matches. */
+typealias FunctionFilter = Set
+
+object YamlUnitTestRunner {
+
+ private val yamlMapper = com.fasterxml.jackson.databind.ObjectMapper(YAMLFactory()).apply {
+ registerModule(
+ kotlinModule {
+ enable(KotlinFeature.NullIsSameAsDefault)
+ enable(KotlinFeature.NullToEmptyMap)
+ enable(KotlinFeature.NullToEmptyCollection)
+ }
+ )
+ }
+
+ fun parseSuite(yamlContent: String): YamlUnitTestSuite = yamlMapper.readValue(yamlContent)
+
+ /**
+ * Runs a single *.tests.yaml suite and appends results to [resultContext].
+ * [yamlPath] is the path to the .tests.yaml file; [basePath] is the directory containing it (used for resolving islSource and mockSource).
+ * When [functionFilter] is non-empty, only test entries whose functionName matches (or "suiteFile:functionName") are run.
+ */
+ fun runSuite(
+ yamlPath: Path,
+ basePath: Path,
+ resultContext: TestResultContext,
+ contextCustomizers: List<(com.intuit.isl.common.IOperationContext) -> Unit>,
+ functionFilter: FunctionFilter = emptySet(),
+ verbose: Boolean = false
+ ) {
+ val suite = parseSuite(yamlPath.toFile().readText())
+ val setup = suite.setup
+ val islPath = basePath.resolve(setup.islSource).normalize()
+ val islFile = islPath.toFile()
+ if (!islFile.exists() || !islFile.isFile) {
+ resultContext.testResults.add(
+ TestResult(
+ testFile = yamlPath.toString(),
+ functionName = "setup",
+ testName = suite.category ?: yamlPath.nameWithoutExtension,
+ testGroup = suite.category ?: yamlPath.nameWithoutExtension,
+ success = false,
+ message = "ISL file not found: $islPath (islSource: ${setup.islSource})"
+ )
+ )
+ return
+ }
+ val islContent = islFile.readText()
+ val moduleName = basePath.relativize(islPath).toString().replace("\\", "/")
+ val fileInfos = mutableListOf(FileInfo(moduleName, islContent))
+ val resolvedPaths = mutableMapOf()
+ resolvedPaths[moduleName] = islPath.toAbsolutePath().normalize()
+ val findExternalModule = IslModuleResolver.createModuleFinder(basePath, resolvedPaths)
+ val transformPackage: TransformPackage = try {
+ TransformPackageBuilder().build(fileInfos, findExternalModule)
+ } catch (e: Exception) {
+ resultContext.testResults.add(
+ TestResult(
+ testFile = moduleName,
+ functionName = "compilation",
+ testName = suite.category ?: yamlPath.nameWithoutExtension,
+ testGroup = suite.category ?: yamlPath.nameWithoutExtension,
+ success = false,
+ message = (e as? TransformCompilationException)?.message ?: e.toString(),
+ errorPosition = (e as? TransformCompilationException)?.position
+ )
+ )
+ return
+ }
+
+ val groupName = suite.category ?: yamlPath.nameWithoutExtension
+ val suiteFileName = yamlPath.fileName.toString()
+
+ val testsToRun = if (functionFilter.isEmpty()) suite.entries else {
+ suite.entries.filter { entry ->
+ functionFilter.any { filter ->
+ when {
+ filter.contains(":") -> {
+ val parts = filter.split(":", limit = 2)
+ val fileMatch = parts[0].equals(suiteFileName, true) ||
+ parts[0].equals(yamlPath.nameWithoutExtension, true)
+ fileMatch && parts[1].equals(entry.functionName, true)
+ }
+ else -> filter.equals(entry.functionName, true)
+ }
+ }
+ }
+ }
+
+ val opts = suite.assertOptions ?: AssertOptions()
+ testsToRun.forEachIndexed { index, entry ->
+ val testResult = runOneTest(
+ transformPackage = transformPackage,
+ moduleName = moduleName,
+ entry = entry,
+ setup = setup,
+ basePath = basePath,
+ groupName = groupName,
+ yamlPath = yamlPath,
+ contextCustomizers = contextCustomizers,
+ assertOptions = opts,
+ printMockSummary = verbose && (index == 0),
+ verbose = verbose
+ )
+ resultContext.testResults.add(testResult)
+ }
+ }
+
+ private fun runOneTest(
+ transformPackage: TransformPackage,
+ moduleName: String,
+ entry: YamlUnitTestEntry,
+ setup: YamlTestSetup,
+ basePath: Path,
+ groupName: String,
+ yamlPath: Path,
+ contextCustomizers: List<(com.intuit.isl.common.IOperationContext) -> Unit>,
+ assertOptions: AssertOptions = AssertOptions(),
+ printMockSummary: Boolean = false,
+ verbose: Boolean = false
+ ): TestResult {
+ val testFileName = basePath.relativize(yamlPath).toString().replace("\\", "/")
+ val context = TestOperationContext.create(
+ testResultContext = TestResultContext(),
+ currentFile = moduleName,
+ basePath = basePath,
+ mockFileName = setup.mockSourceDisplayName(),
+ testFileName = testFileName,
+ contextCustomizers = contextCustomizers
+ )
+
+ try {
+ // 1. Load mockSource file(s) in order (each can override the previous)
+ for (mockFileEntry in setup.mockSourceFiles()) {
+ val mockPath = basePath.resolve(mockFileEntry).normalize()
+ val mockFile = mockPath.toFile()
+ if (mockFile.exists() && mockFile.isFile) {
+ val ext = mockFile.extension.lowercase()
+ val root = when (ext) {
+ "json" -> JsonConvert.mapper.readTree(mockFile)
+ "yaml", "yml" -> com.fasterxml.jackson.databind.ObjectMapper(YAMLFactory()).readTree(mockFile)
+ else -> throw IllegalArgumentException("Mock file must be .json, .yaml, .yml; got: $mockFileEntry")
+ }
+ if (root.isObject) MockFunction.applyMocksFromNode(context, root as ObjectNode, mockFileEntry)
+ }
+ }
+ // 2. Apply inline mocks after mockSource (all mocks are additive; params differentiate)
+ setup.mocksAsObject()?.let { MockFunction.applyMocksFromNode(context, it, testFileName) }
+
+ if (printMockSummary) {
+ val names = (context.mockExtensions.mockExtensions.keys +
+ context.mockExtensions.mockAnnotations.keys +
+ context.mockExtensions.mockStatementExtensions.keys).sorted()
+ val n = names.size
+ if (n > 0) println("[ISL Mock] Mocked M $n function(s): ${names.joinToString(", ")}")
+ }
+
+ // 3. Set input variables (param names from function, or from input map keys)
+ val paramNames = getFunctionParamNames(transformPackage, moduleName, entry.functionName)
+ setInputVariables(context, entry.input, paramNames)
+
+ // 4. Run the function (or capture result from @.Test.Exit(...))
+ if (verbose) println("[ISL Mock] Running ${entry.functionName}")
+ val fullName = TransformPackage.toFullFunctionName(moduleName, entry.functionName)
+ val result = try {
+ transformPackage.runTransformNew(fullName, context)
+ } catch (e: TestExitException) {
+ val r = e.result
+ when {
+ r == null -> null
+ r is JsonNode && r.isNull -> null
+ else -> JsonConvert.convert(r)
+ }
+ } catch (e: TransformException) {
+ val testExit = e.cause as? TestExitException
+ if (testExit != null) {
+ val r = testExit.result
+ when {
+ r == null -> null
+ r is JsonNode && r.isNull -> null
+ else -> JsonConvert.convert(r)
+ }
+ } else {
+ throw e
+ }
+ }
+
+ // 5. Compare with expected (deep compare; on failure report exact field diffs)
+ val expected = entry.expected
+ val opts = entry.assertOptions ?: assertOptions
+ val ignorePaths = (entry.ignore.orEmpty()).map { normalizeComparePath(it) }.toSet()
+ val (success, diffs) = if (expected == null) {
+ (result == null) to emptyList()
+ } else {
+ jsonDeepCompare(expected, result, assertOptions = opts, ignorePaths = ignorePaths)
+ }
+ val message = if (!success && expected != null) {
+ buildComparisonFailureMessage(expected, result, diffs, entry.ignore.orEmpty())
+ } else null
+ val expectedJson = if (!success && expected != null) JsonConvert.mapper.writeValueAsString(expected) else null
+ val actualJson = if (!success) (result?.let { JsonConvert.mapper.writeValueAsString(it) } ?: "null") else null
+ val comparisonDiffs = if (!success && diffs.isNotEmpty()) diffs.map { ComparisonDiff(it.path, it.expectedValue, it.actualValue) } else null
+
+ return TestResult(
+ testFile = yamlPath.toString(),
+ functionName = entry.functionName,
+ testName = entry.name,
+ testGroup = groupName,
+ success = success,
+ message = message,
+ expectedJson = expectedJson,
+ actualJson = actualJson,
+ comparisonDiffs = comparisonDiffs
+ )
+ } catch (e: Exception) {
+ val (msg, pos) = when (e) {
+ is TransformCompilationException -> e.message to e.position
+ is TransformException -> e.message to e.position
+ is com.intuit.isl.runtime.IslException -> e.message to e.position
+ else -> e.message to null
+ }
+ return TestResult(
+ testFile = yamlPath.toString(),
+ functionName = entry.functionName,
+ testName = entry.name,
+ testGroup = groupName,
+ success = false,
+ message = msg ?: e.toString(),
+ errorPosition = pos
+ )
+ }
+ }
+
+ private fun getFunctionParamNames(pkg: TransformPackage, moduleName: String, functionName: String): List {
+ val module = pkg.getModule(moduleName)?.module ?: return emptyList()
+ val func = module.getFunction(functionName) ?: return emptyList()
+ return func.token.arguments.map { it.name }
+ }
+
+ private fun setInputVariables(context: TestOperationContext, input: Any?, paramNames: List) {
+ if (input == null) return
+ if (paramNames.size == 1) {
+ val varName = if (paramNames.first().startsWith("$")) paramNames.first() else "$${paramNames.first()}"
+ context.setVariable(varName, JsonConvert.convert(input))
+ return
+ }
+ if (input is Map<*, *>) {
+ @Suppress("UNCHECKED_CAST")
+ val map = input as Map
+ for ((key, value) in map) {
+ val varName = if (key.startsWith("$")) key else "$$key"
+ context.setVariable(varName, JsonConvert.convert(value))
+ }
+ }
+ }
+
+ /** Path + expected/actual value at a difference; path uses $root.field.[0].key format. */
+ private data class JsonDiff(val path: String, val expectedValue: String, val actualValue: String)
+
+ /**
+ * Normalizes a user-facing JSON path to the format used during comparison ($.key.[0].field).
+ * User may write "providerResponses.items[0].uid" or "providerResponses.error.detail".
+ */
+ private fun normalizeComparePath(userPath: String): String {
+ val t = userPath.trim()
+ if (t.isEmpty()) return "$"
+ val withRoot = if (t.startsWith("$")) t else "$.$t"
+ return withRoot.replace(Regex("(? = emptySet()
+ ): Pair> {
+ if (path in ignorePaths) return true to emptyList()
+ // actual is null (missing or literal null)
+ if (actual == null) {
+ if (expected.isNull) return true to emptyList()
+ if (assertOptions.nullSameAsEmptyArray && expected.isArray && expected.size() == 0) return true to emptyList()
+ return false to listOf(JsonDiff(path, formatJsonValue(expected), "null"))
+ }
+ // expected is null
+ if (expected.isNull) {
+ if (actual.isNull) return true to emptyList()
+ if (assertOptions.nullSameAsEmptyArray && actual.isArray && actual.size() == 0) return true to emptyList()
+ return false to listOf(JsonDiff(path, "null", formatJsonValue(actual)))
+ }
+ if (expected.isNumber && actual.isNumber) {
+ val eq = if (assertOptions.numbersEqualIgnoreFormat) {
+ expected.decimalValue().compareTo(actual.decimalValue()) == 0
+ } else {
+ expected.decimalValue() == actual.decimalValue()
+ }
+ return if (eq) true to emptyList()
+ else false to listOf(JsonDiff(path, formatJsonValue(expected), formatJsonValue(actual)))
+ }
+ if (expected.nodeType != actual.nodeType) {
+ return false to listOf(JsonDiff(path, formatJsonValue(expected), formatJsonValue(actual)))
+ }
+ when {
+ expected.isObject -> {
+ if (!actual.isObject) {
+ return false to listOf(JsonDiff(path, formatJsonValue(expected), formatJsonValue(actual)))
+ }
+ val allKeys = if (assertOptions.ignoreExtraFieldsInActual) {
+ expected.fieldNames().asSequence().toList()
+ } else {
+ (expected.fieldNames().asSequence().toSet() + actual.fieldNames().asSequence().toSet()).toList()
+ }
+ val acc = mutableListOf()
+ for (k in allKeys) {
+ val expectedChild = expected.get(k)
+ val actualChild = actual.get(k)
+ val subPath = if (path == "$") "$$k" else "$path.$k"
+ when {
+ expectedChild == null && actualChild == null -> {}
+ expectedChild == null -> {
+ // extra in actual (only when not ignoreExtraFieldsInActual)
+ if (assertOptions.missingSameAsEmptyArray && actualChild != null && actualChild.isArray && actualChild.size() == 0) {}
+ else acc.add(JsonDiff(subPath, "missing", formatJsonValue(actualChild!!)))
+ }
+ actualChild == null -> {
+ // missing in actual
+ if (assertOptions.nullSameAsMissing && expectedChild.isNull) {}
+ else if (assertOptions.missingSameAsEmptyArray && expectedChild.isArray && expectedChild.size() == 0) {}
+ else acc.add(JsonDiff(subPath, formatJsonValue(expectedChild), "missing"))
+ }
+ else -> {
+ val (ok, subDiffs) = jsonDeepCompare(expectedChild, actualChild, subPath, assertOptions, ignorePaths)
+ if (!ok) acc.addAll(subDiffs)
+ }
+ }
+ }
+ if (!assertOptions.ignoreExtraFieldsInActual && expected.size() != actual.size()) {
+ acc.add(JsonDiff(path, "object size ${expected.size()}", "object size ${actual.size()}"))
+ }
+ return (acc.isEmpty()) to acc
+ }
+ expected.isArray -> {
+ if (!actual.isArray) {
+ return false to listOf(JsonDiff(path, formatJsonValue(expected), formatJsonValue(actual)))
+ }
+ val acc = mutableListOf()
+ val size = minOf(expected.size(), actual.size())
+ for (i in 0 until size) {
+ val indexPath = if (path == "$") "$[$i]" else "$path.[$i]"
+ val (ok, subDiffs) = jsonDeepCompare(expected.get(i), actual.get(i), indexPath, assertOptions, ignorePaths)
+ if (!ok) acc.addAll(subDiffs)
+ }
+ if (expected.size() != actual.size()) {
+ acc.add(JsonDiff(path, "array size ${expected.size()}", "array size ${actual.size()}"))
+ }
+ return (acc.isEmpty()) to acc
+ }
+ else -> {
+ val eq = expected.equals(actual)
+ return if (eq) true to emptyList()
+ else false to listOf(JsonDiff(path, formatJsonValue(expected), formatJsonValue(actual)))
+ }
+ }
+ }
+
+ private fun formatJsonValue(node: JsonNode): String =
+ JsonConvert.mapper.writeValueAsString(node)
+
+ private fun buildComparisonFailureMessage(
+ expected: JsonNode,
+ actual: JsonNode?,
+ diffs: List,
+ ignoredPaths: List = emptyList()
+ ): String {
+ val fullExpected = JsonConvert.mapper.writeValueAsString(expected)
+ val fullActual = actual?.let { JsonConvert.mapper.writeValueAsString(it) } ?: "null"
+ val header = "Expected: $fullExpected\nActual: $fullActual"
+ if (diffs.isEmpty()) return header
+ val diffLines = diffs.joinToString("\n") { d ->
+ "Expected: ${d.path} = ${d.expectedValue}\nActual: ${d.path} = ${d.actualValue}\r\n"
+ }
+ val ignoredSection = if (ignoredPaths.isEmpty()) ""
+ else "\n[ISL Assert] Ignored path(s):\n${ignoredPaths.joinToString("\n") { " $it" }}\n"
+ return "[ISL Assert] Result Differences:\n$header$ignoredSection\n[ISL Assert] Difference(s):\n$diffLines\n"
+ }
+}
diff --git a/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestSuite.kt b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestSuite.kt
new file mode 100644
index 0000000..b5850e8
--- /dev/null
+++ b/isl-cmd/src/main/kotlin/com/intuit/isl/cmd/YamlUnitTestSuite.kt
@@ -0,0 +1,149 @@
+package com.intuit.isl.cmd
+
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonDeserializer
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.node.ObjectNode
+import com.intuit.isl.utils.JsonConvert
+import java.io.IOException
+
+/**
+ * Options for comparing expected vs actual in YAML test assertions.
+ * All default to false (strict comparison).
+ *
+ * In YAML, assertOptions can be written as:
+ * - Object: `assertOptions: { nullSameAsMissing: true, ... }`
+ * - Comma-separated list: `assertOptions: nullSameAsMissing, nullSameAsEmptyArray, ...`
+ * - Array: `assertOptions: [nullSameAsMissing, nullSameAsEmptyArray, ...]`
+ */
+@JsonDeserialize(using = AssertOptionsDeserializer::class)
+data class AssertOptions(
+ /** Treat null and missing (absent key) as equal. */
+ val nullSameAsMissing: Boolean = false,
+ /** Treat null and empty array [] as equal. */
+ val nullSameAsEmptyArray: Boolean = false,
+ /** Treat missing (absent key) and empty array [] as equal. */
+ val missingSameAsEmptyArray: Boolean = false,
+ /** Only compare keys present in expected; ignore extra keys in actual. */
+ val ignoreExtraFieldsInActual: Boolean = false,
+ /** Compare numbers by numeric value only (e.g. 1234.0 equals 1234 equals 1234.00). */
+ val numbersEqualIgnoreFormat: Boolean = false
+) {
+ companion object {
+ private val OPTION_NAMES = setOf(
+ "nullSameAsMissing",
+ "nullSameAsEmptyArray",
+ "missingSameAsEmptyArray",
+ "ignoreExtraFieldsInActual",
+ "numbersEqualIgnoreFormat"
+ )
+
+ fun fromNames(names: List): AssertOptions {
+ val normalized = names.map { it.trim() }.filter { it.isNotEmpty() }.filter { it in OPTION_NAMES }.toSet()
+ return AssertOptions(
+ nullSameAsMissing = "nullSameAsMissing" in normalized,
+ nullSameAsEmptyArray = "nullSameAsEmptyArray" in normalized,
+ missingSameAsEmptyArray = "missingSameAsEmptyArray" in normalized,
+ ignoreExtraFieldsInActual = "ignoreExtraFieldsInActual" in normalized,
+ numbersEqualIgnoreFormat = "numbersEqualIgnoreFormat" in normalized
+ )
+ }
+ }
+}
+
+/**
+ * Deserializes assertOptions from either an object (boolean keys) or a string/array of option names.
+ */
+class AssertOptionsDeserializer : JsonDeserializer() {
+ @Throws(IOException::class)
+ override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AssertOptions {
+ val node: JsonNode = p.codec.readTree(p) ?: return AssertOptions()
+ return when {
+ node.isObject -> deserializeObject(node)
+ node.isTextual -> AssertOptions.fromNames(node.asText().split(','))
+ node.isArray -> AssertOptions.fromNames(node.map { if (it.isTextual) it.asText() else "" })
+ else -> AssertOptions()
+ }
+ }
+
+ private fun deserializeObject(node: JsonNode): AssertOptions {
+ return AssertOptions(
+ nullSameAsMissing = node.path("nullSameAsMissing").asBoolean(false),
+ nullSameAsEmptyArray = node.path("nullSameAsEmptyArray").asBoolean(false),
+ missingSameAsEmptyArray = node.path("missingSameAsEmptyArray").asBoolean(false),
+ ignoreExtraFieldsInActual = node.path("ignoreExtraFieldsInActual").asBoolean(false),
+ numbersEqualIgnoreFormat = node.path("numbersEqualIgnoreFormat").asBoolean(false)
+ )
+ }
+}
+
+/**
+ * YAML-driven unit test suite (e.g. *.tests.yaml).
+ * Format:
+ * - category: name of test group
+ * - setup: islSource, optional mockSource, optional inline mocks (applied after mockSource so they override)
+ * - assertOptions: optional assertion comparison options
+ * - tests or islTests: list of test entries with name, functionName, optional input, expected result
+ */
+data class YamlUnitTestSuite(
+ val category: String? = null,
+ val setup: YamlTestSetup,
+ val assertOptions: AssertOptions? = null,
+ @com.fasterxml.jackson.annotation.JsonProperty("tests") val tests: List? = null,
+ @com.fasterxml.jackson.annotation.JsonProperty("islTests") val islTests: List? = null
+) {
+ /** Test entries from either "tests" or "islTests" YAML key (for backward compatibility). */
+ val entries: List
+ get() = (islTests ?: tests).orEmpty()
+}
+
+data class YamlTestSetup(
+ val islSource: String,
+ /**
+ * Mock file(s) to load (same format as @.Mock.Load).
+ * - Single string: mockSource: mymocks.yaml
+ * - Array: mockSource: [commonMocks.yaml, otherMocks.yaml] — loaded in order, each overrides the previous.
+ */
+ val mockSource: JsonNode? = null,
+ /** Inline mocks in same format as @.Mock.Load (func/annotation arrays). Applied after mockSource; all mocks are additive (params differentiate). Uses Map so Jackson reliably deserializes nested YAML. */
+ val mocks: Map? = null
+) {
+ /** Converts inline mocks to ObjectNode for applyMocksFromNode. Handles both Map (from YAML) and ensures func/annotation structure. */
+ fun mocksAsObject(): ObjectNode? {
+ val map = mocks ?: return null
+ val node: JsonNode = JsonConvert.mapper.valueToTree(map)
+ return if (node.isObject) node as ObjectNode else null
+ }
+
+ /** Resolves mockSource to a list of file names: one for a string, many for an array, empty if null. */
+ fun mockSourceFiles(): List = when {
+ mockSource == null || mockSource.isNull -> emptyList()
+ mockSource.isTextual -> listOf(mockSource.asText().trim()).filter { it.isNotEmpty() }
+ mockSource.isArray -> mockSource.mapNotNull { if (it.isTextual) it.asText().trim().takeIf { s -> s.isNotEmpty() } else null }
+ else -> emptyList()
+ }
+
+ /** Last mock source file name (for error messages), or null if none. */
+ fun mockSourceDisplayName(): String? = mockSourceFiles().lastOrNull()
+}
+
+data class YamlUnitTestEntry(
+ val name: String,
+ val functionName: String,
+ val byPassAnnotations: Boolean? = null,
+ /** Single value for single-param functions, or object with param names as keys for multiple params. */
+ val input: Any? = null,
+ /**
+ * JSON paths to ignore when comparing expected vs actual (exact path match).
+ * Paths use dot notation; array indices as [0], [1], etc. Examples:
+ * - providerResponses.error.detail
+ * - providerResponses.items[0].uid
+ */
+ val ignore: List? = null,
+ val expected: JsonNode? = null,
+ /** Override suite assertOptions for this test only. Same formats as suite assertOptions (object, comma-separated, or array). */
+ val assertOptions: AssertOptions? = null
+)
diff --git a/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt b/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt
index 8e18bc7..54ab995 100644
--- a/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt
+++ b/isl-cmd/src/test/kotlin/com/intuit/isl/cmd/IslCommandLineTest.kt
@@ -114,6 +114,35 @@ class IslCommandLineTest {
assertTrue(output.contains("true"))
}
+ @Test
+ fun `test transform with Log extensions`(@TempDir tempDir: Path) {
+ val scriptFile = tempDir.resolve("log-test.isl").toFile()
+ scriptFile.writeText("""
+ fun run(${'$'}input) {
+ @.Log.Info("Processing", ${'$'}input.name)
+ result: { message: "done" }
+ }
+ """.trimIndent())
+
+ val inputFile = tempDir.resolve("input.json").toFile()
+ inputFile.writeText("""{"name": "test"}""")
+
+ val outputStream = ByteArrayOutputStream()
+ System.setOut(PrintStream(outputStream))
+
+ val cmd = CommandLine(IslCommandLine())
+ val exitCode = cmd.execute(
+ "transform",
+ scriptFile.absolutePath,
+ "-i", inputFile.absolutePath,
+ "--pretty"
+ )
+
+ assertEquals(0, exitCode)
+ val output = outputStream.toString()
+ assertTrue(output.contains("[INFO]") && output.contains("Processing"), "Expected [INFO] in output: $output")
+ }
+
@Test
fun `test validate command with valid script`(@TempDir tempDir: Path) {
val scriptFile = tempDir.resolve("valid.isl").toFile()
diff --git a/isl-test/build.gradle.kts b/isl-test/build.gradle.kts
new file mode 100644
index 0000000..878807a
--- /dev/null
+++ b/isl-test/build.gradle.kts
@@ -0,0 +1,93 @@
+plugins {
+ kotlin("jvm")
+ id("jacoco")
+ id("org.gradle.test-retry") version "1.6.0"
+}
+
+val kotlinVersion: String = "2.1.10"
+val kotlinCoroutinesVersion: String = "1.10.1"
+val jacksonVersion: String = "2.18.3"
+
+dependencies {
+ // Project dependency
+ implementation(project(":isl-transform"))
+
+ // Kotlin
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
+ implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion")
+
+ // Jackson (needed for JSON/YAML when using isl-transform)
+ implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
+ implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
+ implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion")
+ implementation("com.opencsv:opencsv:5.10")
+
+ // Logging
+ implementation("org.slf4j:slf4j-api:2.0.17")
+ implementation("ch.qos.logback:logback-classic:1.5.16")
+
+ // Testing
+ testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:$kotlinVersion")
+ testImplementation("org.junit.jupiter:junit-jupiter-api:5.12.1")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.12.1")
+ testImplementation("org.junit.jupiter:junit-jupiter-params:5.12.1")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.1")
+ testImplementation("io.mockk:mockk-jvm:1.13.17")
+}
+
+sourceSets {
+ main {
+ kotlin {
+ srcDirs("src/main/kotlin")
+ }
+ }
+ test {
+ kotlin {
+ srcDirs("src/test/kotlin")
+ }
+ }
+}
+
+tasks.jacocoTestReport {
+ dependsOn(tasks.test, tasks.processResources, tasks.classes)
+}
+
+tasks.jacocoTestCoverageVerification {
+ dependsOn(tasks.jacocoTestReport, tasks.classes)
+ violationRules {
+ rule {
+ limit {
+ counter = "LINE"
+ value = "COVEREDRATIO"
+ minimum = "0.0".toBigDecimal()
+ }
+ }
+ }
+ classDirectories.setFrom(tasks.jacocoTestReport.get().classDirectories)
+}
+
+tasks.test {
+ finalizedBy(tasks.jacocoTestReport)
+ retry {
+ maxRetries.set(3)
+ maxFailures.set(10)
+ }
+}
+
+tasks.check {
+ dependsOn(tasks.jacocoTestCoverageVerification)
+}
+
+tasks.jar {
+ manifest {
+ attributes(
+ "Implementation-Title" to project.name,
+ "Implementation-Version" to project.version,
+ "Specification-Title" to project.name,
+ "Specification-Version" to project.version
+ )
+ }
+}
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt
new file mode 100644
index 0000000..0ad7917
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/LoadFunction.kt
@@ -0,0 +1,98 @@
+package com.intuit.isl.test
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.node.JsonNodeFactory
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import com.intuit.isl.common.FunctionExecuteContext
+import com.intuit.isl.common.IOperationContext
+import com.intuit.isl.utils.ConvertUtils
+import com.intuit.isl.utils.JsonConvert
+import com.opencsv.CSVParserBuilder
+import com.opencsv.CSVReaderBuilder
+import java.io.File
+import java.io.StringReader
+import java.nio.file.Path
+
+/**
+ * Loads resources from files relative to the current ISL file.
+ * Use @.Load.From("fileName") where fileName is relative to the directory of the current file.
+ * Supports .json, .yaml, .yml, and .csv - all converted to JSON.
+ */
+object LoadFunction {
+ private const val functionName = "Load"
+
+ fun registerExtensions(context: IOperationContext) {
+ context.registerExtensionMethod("$functionName.From") { ctx ->
+ from(ctx)
+ }
+ }
+
+ private fun from(context: FunctionExecuteContext): Any? {
+ val fileName = ConvertUtils.tryToString(context.firstParameter)
+ ?: throw IllegalArgumentException("@.Load.From requires a file name (string)")
+
+ val testContext = context.executionContext.operationContext as? TestOperationContext
+ ?: throw IllegalStateException("@.Load.From is only available in test context")
+
+ val basePath = testContext.basePath
+ ?: throw IllegalStateException("@.Load.From requires basePath; run tests via isl test command or pass basePath to TransformTestPackageBuilder")
+
+ val currentFile = testContext.currentFile
+ ?: throw IllegalStateException("@.Load.From requires currentFile; run tests via isl test command")
+
+ val resolvedPath = resolvePath(basePath, currentFile, fileName)
+ val file = resolvedPath.toFile()
+
+ if (!file.exists()) {
+ throw IllegalArgumentException("File not found: $resolvedPath (resolved from $fileName relative to $currentFile)")
+ }
+ if (!file.isFile) {
+ throw IllegalArgumentException("Not a file: $resolvedPath")
+ }
+
+ val ext = file.extension.lowercase()
+ return when (ext) {
+ "json" -> JsonConvert.mapper.readTree(file)
+ "yaml", "yml" -> {
+ val yamlMapper = com.fasterxml.jackson.databind.ObjectMapper(YAMLFactory())
+ yamlMapper.readTree(file)
+ }
+ "csv" -> parseCsvToJson(file.readText())
+ else -> throw IllegalArgumentException(
+ "@.Load.From supports .json, .yaml, .yml, .csv; got: $fileName"
+ )
+ }
+ }
+
+ private fun resolvePath(basePath: Path, currentFile: String, fileName: String): Path {
+ val currentDir = basePath.resolve(currentFile).parent ?: basePath
+ return currentDir.resolve(fileName).normalize()
+ }
+
+ private fun parseCsvToJson(text: String): JsonNode {
+ val parser = CSVParserBuilder()
+ .withSeparator(',')
+ .withEscapeChar('\\')
+ .withIgnoreQuotations(false)
+ .build()
+ val reader = CSVReaderBuilder(StringReader(text))
+ .withSkipLines(0)
+ .withCSVParser(parser)
+ .build()
+
+ val result = JsonNodeFactory.instance.arrayNode()
+ val firstLine = reader.readNext() ?: return result
+ val headers = firstLine
+
+ var line: Array?
+ while (reader.readNext().also { line = it } != null) {
+ val item = JsonNodeFactory.instance.objectNode()
+ line?.forEachIndexed { i: Int, value: String? ->
+ val key = if (i < headers.size) headers[i] ?: "Col$i" else "Col$i"
+ item.put(key, value)
+ }
+ result.add(item)
+ }
+ return result
+ }
+}
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt
new file mode 100644
index 0000000..3c9d2d5
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationContext.kt
@@ -0,0 +1,200 @@
+package com.intuit.isl.test
+
+import com.intuit.isl.test.annotations.SetupAnnotation
+import com.intuit.isl.test.annotations.TestAnnotation
+import com.intuit.isl.test.annotations.TestResultContext
+import com.intuit.isl.test.assertions.AssertFunction
+import com.intuit.isl.common.*
+import com.intuit.isl.test.mocks.MockFunction
+import com.intuit.isl.runtime.TransformException
+import com.intuit.isl.utils.JsonConvert
+import java.nio.file.Path
+
+/** Sentinel used to register a fallback handler for any function call that is not mocked. */
+private const val FALLBACK_METHOD_NAME = "*"
+
+class TestOperationContext : BaseOperationContext {
+ /** Current ISL file path (module name, e.g. "tests/sample.isl") for resolving relative paths in @.Load.From() */
+ var currentFile: String? = null
+ internal set
+
+ /** Base path for resolving relative file paths (e.g. project root) */
+ var basePath: Path? = null
+ internal set
+
+ /** Mock file name (e.g. from setup.mockSource) for error messages when an unmocked function is called. */
+ var mockFileName: String? = null
+ internal set
+
+ /** Test file name (e.g. the .tests.yaml path) for error messages; when set, suggest adding mocks to the test file (setup) instead of mock files. */
+ var testFileName: String? = null
+ internal set
+
+ companion object {
+ fun create(
+ testResultContext: TestResultContext,
+ currentFile: String? = null,
+ basePath: Path? = null,
+ mockFileName: String? = null,
+ testFileName: String? = null,
+ contextCustomizers: List<(IOperationContext) -> Unit> = emptyList()
+ ): TestOperationContext {
+ val context = TestOperationContext()
+
+ context.registerAnnotation(SetupAnnotation.annotationName, SetupAnnotation::runAnnotationFunction)
+ TestAnnotation.registerAnnotation(context, testResultContext)
+
+ AssertFunction.registerExtensions(context)
+ LoadFunction.registerExtensions(context)
+ MockFunction.registerExtensions(context)
+
+ context.registerExtensionMethod(FALLBACK_METHOD_NAME) { functionContext ->
+ throw buildUnmockedCallException(functionContext)
+ }
+
+ contextCustomizers.forEach { it(context) }
+
+ context.currentFile = currentFile
+ context.basePath = basePath
+ context.mockFileName = mockFileName
+ context.testFileName = testFileName
+
+ return context
+ }
+
+ private fun buildUnmockedCallException(context: FunctionExecuteContext): TransformException {
+ val functionName = context.functionName
+ val position = context.command.token.position
+ val place = "file=${position.file}, line=${position.line}, column=${position.column}" +
+ (position.endLine?.let { ", endLine=$it" } ?: "") +
+ (position.endColumn?.let { ", endColumn=$it" } ?: "")
+
+ val paramsJson = context.parameters
+ .map { JsonConvert.convert(it) }
+ .let { nodes -> JsonConvert.mapper.writeValueAsString(nodes) }
+
+ val testContext = context.executionContext.operationContext as? TestOperationContext
+ val addToHint = when {
+ testContext?.testFileName != null -> "test file [${testContext.testFileName}] (in setup.mocks or in the test)"
+ else -> "[${testContext?.mockFileName ?: "your-mocks.yaml"}]"
+ }
+
+ val yamlSnippet = buildString {
+ appendLine("- name: \"$functionName\"")
+ if (context.parameters.isNotEmpty()) {
+ appendLine(" params: $paramsJson")
+ }
+ appendLine(" result: ")
+ }
+
+ val message = buildString {
+ appendLine("Unmocked function was called. The test must only call functions that are mocked.")
+ appendLine("Function: @.$functionName")
+ appendLine("Called from: $place")
+ appendLine("Parameters: $paramsJson")
+ appendLine("")
+ appendLine("To mock this function add this to your $addToHint then rerun the tests:")
+ appendLine("")
+ appendLine("func:")
+ appendLine(yamlSnippet)
+ appendLine("")
+ }
+
+ return TransformException(message.trimEnd(), position)
+ }
+
+ private fun buildUnmockedModifierException(modifierKey: String, context: FunctionExecuteContext): TransformException {
+ val displayName = if (modifierKey.lowercase().startsWith("modifier.")) modifierKey.drop("modifier.".length) else modifierKey
+ val position = context.command.token.position
+ val place = "file=${position.file}, line=${position.line}, column=${position.column}" +
+ (position.endLine?.let { ", endLine=$it" } ?: "") +
+ (position.endColumn?.let { ", endColumn=$it" } ?: "")
+
+ val testContext = context.executionContext.operationContext as? TestOperationContext
+ val addToHint = when {
+ testContext?.testFileName != null -> "test file [${testContext.testFileName}] (in setup.mocks or in the test)"
+ else -> "[${testContext?.mockFileName ?: "your-mocks.yaml"}]"
+ }
+
+ val yamlName = "Modifier.$displayName"
+ val yamlSnippet = buildString {
+ appendLine("- name: \"$yamlName\"")
+ appendLine(" result: ")
+ }
+
+ val message = buildString {
+ appendLine("Unmocked modifier was called. The test must only call modifiers that are mocked.")
+ appendLine("Modifier: | $displayName")
+ appendLine("Called from: $place")
+ appendLine("")
+ appendLine("To mock this modifier add this to your $addToHint then rerun the tests:")
+ appendLine("func:")
+ append(yamlSnippet)
+ }
+
+ return TransformException(message.trimEnd(), position)
+ }
+ }
+
+ constructor() : super() {
+ this.mockExtensions = TestOperationMockExtensions()
+ }
+
+ private constructor(
+ extensions: HashMap,
+ annotations: HashMap,
+ statementExtensions: HashMap,
+ internalExtensions: HashMap,
+ mockExtensions: TestOperationMockExtensions,
+ mockFileName: String? = null,
+ testFileName: String? = null
+ ) : super(
+ extensions, annotations, statementExtensions, internalExtensions, HashMap()
+ ) {
+ this.mockExtensions = mockExtensions
+ this.mockFileName = mockFileName
+ this.testFileName = testFileName
+ }
+
+ val mockExtensions : TestOperationMockExtensions
+
+ override fun getExtension(name: String): AsyncContextAwareExtensionMethod? {
+ val function = mockExtensions.mockExtensions[name.lowercase()]?.func
+ if (function != null) {
+ return function
+ }
+ val fromSuper = super.getExtension(name)
+ if (fromSuper != null) {
+ return fromSuper
+ }
+ if (name.lowercase().startsWith("modifier.")) {
+ return { context ->
+ throw buildUnmockedModifierException(name, context)
+ }
+ }
+ return null
+ }
+
+ override fun getAnnotation(annotationName: String): AsyncExtensionAnnotation? {
+ val function = mockExtensions.mockAnnotations[annotationName.lowercase()]?.func
+ if (function != null) {
+ return function
+ }
+ return super.getAnnotation(annotationName)
+ }
+
+ override fun getStatementExtension(name: String): AsyncStatementsExtensionMethod? {
+ val function = mockExtensions.mockStatementExtensions[name.lowercase()]?.func
+ if (function != null) {
+ return function
+ }
+ return super.getStatementExtension(name)
+ }
+
+ override fun clone(newInternals: HashMap): IOperationContext {
+ return TestOperationContext(
+ this.extensions, this.annotations, this.statementExtensions, newInternals, this.mockExtensions, this.mockFileName, this.testFileName
+ )
+ }
+}
+
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt
new file mode 100644
index 0000000..1e33bf0
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TestOperationMockExtensions.kt
@@ -0,0 +1,11 @@
+package com.intuit.isl.test
+
+import com.intuit.isl.commands.CommandResult
+import com.intuit.isl.common.*
+import com.intuit.isl.test.mocks.MockContext
+
+class TestOperationMockExtensions {
+ val mockExtensions = HashMap>()
+ val mockAnnotations = HashMap>()
+ val mockStatementExtensions = HashMap>()
+}
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt
new file mode 100644
index 0000000..8946ca1
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestFile.kt
@@ -0,0 +1,7 @@
+package com.intuit.isl.test
+
+data class TransformTestFile(
+ val fileName: String,
+ val testFunctions: Set = setOf(),
+ val setupFile: String? = null
+)
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt
new file mode 100644
index 0000000..6bacf1f
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackage.kt
@@ -0,0 +1,113 @@
+package com.intuit.isl.test
+
+import com.intuit.isl.common.IOperationContext
+import com.intuit.isl.test.annotations.SetupAnnotation
+import com.intuit.isl.test.annotations.TestAnnotation
+import com.intuit.isl.test.annotations.TestResultContext
+import com.intuit.isl.commands.IFunctionDeclarationCommand
+import com.intuit.isl.runtime.TransformModule
+import com.intuit.isl.runtime.TransformPackage
+import java.nio.file.Path
+
+class TransformTestPackage(
+ private val transformPackage: TransformPackage,
+ private val basePath: Path? = null,
+ private val contextCustomizers: List<(IOperationContext) -> Unit> = emptyList()
+) {
+ private val testFiles = mutableMapOf()
+
+ init {
+ transformPackage.modules.forEach { file ->
+ val module = transformPackage.getModule(file)?.module
+ module?.functions?.forEach { function ->
+ val testFile = verifyIfModuleIsTestFile(function, module, file)
+ if (testFile != null) {
+ val existing = testFiles[file]
+ testFiles[file] = if (existing != null) {
+ TransformTestFile(
+ file,
+ existing.testFunctions + testFile.testFunctions,
+ existing.setupFile ?: testFile.setupFile
+ )
+ } else {
+ testFile
+ }
+ }
+ }
+ }
+ }
+
+ fun runAllTests(testResultContext: TestResultContext? = null) : TestResultContext {
+ return runFilteredTests(testResultContext) { _, _ -> true }
+ }
+
+ /**
+ * Run only tests that match the given predicate.
+ * @param includeTest Predicate (file, function) -> true to run the test
+ */
+ fun runFilteredTests(
+ testResultContext: TestResultContext? = null,
+ includeTest: (file: String, function: String) -> Boolean
+ ): TestResultContext {
+ val context = testResultContext ?: TestResultContext()
+ testFiles.forEach { (_, file) ->
+ file.testFunctions.filter { includeTest(file.fileName, it) }.forEach { function ->
+ runTest(file.fileName, function, context)
+ }
+ }
+ return context
+ }
+
+ fun runTest(testFile: String, testFunc: String, testResultContext: TestResultContext? = null) : TestResultContext {
+ var context = testResultContext ?: TestResultContext()
+ println();
+ println("[ISLTest]>> Start Running=$testFunc");
+ try{
+ runTest(testFile, testFunc, context, testFiles[testFile]?.setupFile)
+ } finally{
+ println("[ISLTest]<< DONE Running=$testFunc");
+ }
+ return context
+ }
+
+ private fun runTest(testFile: String, testFunc: String, testResultContext: TestResultContext, setupFunc: String? = null) {
+ val fullFunctionName = TransformPackage.toFullFunctionName(testFile, testFunc)
+ val context = TestOperationContext.create(testResultContext, testFile, basePath, mockFileName = null, contextCustomizers = contextCustomizers)
+ // Run setup function if it exists
+ if (setupFunc != null) {
+ val fullSetupFunctionName = TransformPackage.toFullFunctionName(testFile, setupFunc)
+ transformPackage.runTransform(fullSetupFunctionName, context)
+ }
+ transformPackage.runTransform(fullFunctionName, context)
+ }
+
+ private fun verifyIfModuleIsTestFile(
+ function: IFunctionDeclarationCommand,
+ module: TransformModule,
+ file: String
+ ): TransformTestFile? {
+ val testFunctions = mutableSetOf()
+ var setUpFunction: String? = null
+ function.token.annotations.forEach { a ->
+ when (a.annotationName) {
+ TestAnnotation.annotationName -> {
+ testFunctions.add(function.token.functionName)
+ }
+
+ SetupAnnotation.annotationName -> {
+ if (setUpFunction != null) {
+ throw Exception("Multiple setUp functions found. File: ${module.name}, Function: ${function.token.functionName}")
+ }
+ setUpFunction = function.token.functionName
+ }
+ }
+ }
+
+ // Mark file as test file if it has any test functions
+ if (testFunctions.isNotEmpty()) {
+ return TransformTestFile(file, testFunctions, setUpFunction)
+ }
+ return null
+ }
+}
+
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt
new file mode 100644
index 0000000..1d64b4e
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/TransformTestPackageBuilder.kt
@@ -0,0 +1,21 @@
+package com.intuit.isl.test
+
+import com.intuit.isl.common.IOperationContext
+import com.intuit.isl.runtime.FileInfo
+import com.intuit.isl.runtime.TransformPackageBuilder
+import java.nio.file.Path
+import java.util.function.BiFunction
+
+class TransformTestPackageBuilder {
+ private val transformPackageBuilder = TransformPackageBuilder()
+
+ fun build(
+ files: MutableList,
+ findExternalModule: BiFunction? = null,
+ basePath: Path? = null,
+ contextCustomizers: List<(IOperationContext) -> Unit> = emptyList()
+ ): TransformTestPackage {
+ val transformPackage = transformPackageBuilder.build(files, findExternalModule)
+ return TransformTestPackage(transformPackage, basePath, contextCustomizers)
+ }
+}
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt
new file mode 100644
index 0000000..253223b
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/SetupAnnotation.kt
@@ -0,0 +1,12 @@
+package com.intuit.isl.test.annotations
+
+import com.intuit.isl.common.AnnotationExecuteContext
+
+object SetupAnnotation {
+ const val annotationName = "setup"
+
+ suspend fun runAnnotationFunction(context: AnnotationExecuteContext) : Any? {
+ return context.runNextCommand()
+ }
+}
+
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt
new file mode 100644
index 0000000..0c7f4d1
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestAnnotation.kt
@@ -0,0 +1,84 @@
+package com.intuit.isl.test.annotations;
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.intuit.isl.test.assertions.AssertException
+import com.intuit.isl.common.IOperationContext
+import com.intuit.isl.runtime.IslException
+import com.intuit.isl.runtime.TransformException
+import com.intuit.isl.utils.ConvertUtils
+
+object TestAnnotation {
+ const val annotationName = "test"
+
+ fun registerAnnotation(operationContext: IOperationContext, testResultContext: TestResultContext) {
+ operationContext.registerAnnotation(annotationName) { context ->
+ var contextCommandOutput : Any? = null
+ val (testName, testGroup) = parseTestAnnotationParams(context)
+ val result = TestResult(
+ testFile = context.command.token.position.file,
+ functionName = context.functionName,
+ testName = testName,
+ testGroup = testGroup,
+ success = true
+ )
+ try {
+ contextCommandOutput = context.runNextCommand()
+ }
+ catch (e: Exception) {
+ result.success = false
+ result.message = e.message
+ result.exception = e
+ if (e is TransformException && e.cause != null) {
+ e.cause.let {
+ if (it is AssertException) {
+ // Surface the assertion exception message
+ result.message = it.message
+ result.errorPosition = it.position
+ result.exception = it
+ }
+ }
+ }
+ if (e is IslException) {
+ result.errorPosition = e.position
+ }
+
+ }
+ finally {
+ testResultContext.testResults.add(result)
+ }
+
+ contextCommandOutput
+ }
+ }
+
+ /**
+ * Parse @test annotation parameters.
+ * Supports: @test(), @test(name), @test(name, group), @test({ name: "x", group: "y" })
+ */
+ private fun parseTestAnnotationParams(context: com.intuit.isl.common.AnnotationExecuteContext): Pair {
+ val functionName = context.functionName
+ val testFile = context.command.token.position.file
+ val defaultGroup = testFile.substringAfterLast('/').substringAfterLast('\\')
+ val params = context.parameters
+ return when {
+ params.isEmpty() -> Pair(functionName, defaultGroup)
+ params.size == 1 -> {
+ val first = params[0]
+ when {
+ first is JsonNode && first.isObject -> {
+ val name = first.path("name").takeIf { !it.isMissingNode }?.asText() ?: functionName
+ val group = first.path("group").takeIf { !it.isMissingNode }?.asText()
+ Pair(name, group)
+ }
+ else -> Pair(ConvertUtils.tryToString(first) ?: functionName, defaultGroup)
+ }
+ }
+ params.size >= 2 -> {
+ val name = ConvertUtils.tryToString(params[0]) ?: functionName
+ val group = ConvertUtils.tryToString(params[1])
+ Pair(name, group)
+ }
+ else -> Pair(functionName, defaultGroup)
+ }
+ }
+}
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt
new file mode 100644
index 0000000..ad3b140
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResult.kt
@@ -0,0 +1,23 @@
+package com.intuit.isl.test.annotations
+
+import com.intuit.isl.utils.Position
+import java.lang.Exception
+
+/** One path-level difference (path, expected value, actual value) for report rendering. */
+data class ComparisonDiff(val path: String, val expectedValue: String, val actualValue: String)
+
+data class TestResult(
+ val testFile: String,
+ val functionName: String,
+ val testName: String,
+ val testGroup: String?,
+ var success: Boolean,
+ var message: String? = null,
+ var errorPosition: Position? = null,
+ var exception: Exception? = null,
+ /** When set (e.g. YAML test comparison failure), report can render expected/actual in ```json blocks. */
+ var expectedJson: String? = null,
+ var actualJson: String? = null,
+ /** Per-path differences for markdown report (Expected:/Actual: blocks). */
+ var comparisonDiffs: List? = null
+)
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt
new file mode 100644
index 0000000..123d388
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/annotations/TestResultContext.kt
@@ -0,0 +1,3 @@
+package com.intuit.isl.test.annotations
+
+data class TestResultContext(val testResults: MutableList = mutableListOf())
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt
new file mode 100644
index 0000000..820d2d9
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertException.kt
@@ -0,0 +1,11 @@
+package com.intuit.isl.test.assertions
+
+import com.intuit.isl.runtime.IslException
+import com.intuit.isl.utils.Position
+
+open class AssertException(
+ message: String,
+ val functionName: String,
+ override val position: Position? = null,
+ cause: Throwable? = null
+) : IslException, Exception(message, cause)
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt
new file mode 100644
index 0000000..9959444
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/AssertFunction.kt
@@ -0,0 +1,257 @@
+package com.intuit.isl.test.assertions
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.node.ArrayNode
+import com.fasterxml.jackson.databind.node.ObjectNode
+import com.intuit.isl.test.TestOperationContext
+import com.intuit.isl.commands.ConditionEvaluator
+import com.intuit.isl.common.FunctionExecuteContext
+import com.intuit.isl.utils.ConvertUtils
+import com.intuit.isl.utils.JsonConvert
+
+object AssertFunction {
+ private const val functionName = "Assert"
+
+ fun registerExtensions(context: TestOperationContext) {
+ mapOf Any?>(
+ AssertFunction::equal.name to AssertFunction::equal,
+ AssertFunction::notEqual.name to AssertFunction::notEqual,
+ AssertFunction::lessThan.name to AssertFunction::lessThan,
+ AssertFunction::lessThanOrEqual.name to AssertFunction::lessThanOrEqual,
+ AssertFunction::greaterThan.name to AssertFunction::greaterThan,
+ AssertFunction::greaterThanOrEqual.name to AssertFunction::greaterThanOrEqual,
+ AssertFunction::matches.name to AssertFunction::matches,
+ AssertFunction::notMatches.name to AssertFunction::notMatches,
+ AssertFunction::contains.name to AssertFunction::contains,
+ AssertFunction::notContains.name to AssertFunction::notContains,
+ AssertFunction::startsWith.name to AssertFunction::startsWith,
+ AssertFunction::notStartsWith.name to AssertFunction::notStartsWith,
+ AssertFunction::endsWith.name to AssertFunction::endsWith,
+ AssertFunction::notEndsWith.name to AssertFunction::notEndsWith,
+ "in" to AssertFunction::assertIn,
+ AssertFunction::notIn.name to AssertFunction::notIn,
+ AssertFunction::isType.name to AssertFunction::isType,
+ AssertFunction::isNotType.name to AssertFunction::isNotType,
+ AssertFunction::notNull.name to AssertFunction::notNull,
+ AssertFunction::isNull.name to AssertFunction::isNull
+ ).forEach { (t, u) ->
+ registerExtensionMethod(context, t, u)
+ }
+ }
+
+ private fun equal(context: FunctionExecuteContext): Any? {
+ return evaluateCondition(context, "==")
+ }
+
+ private fun notEqual(context: FunctionExecuteContext): Any? {
+ return evaluateCondition(context, "!=")
+ }
+
+ private fun lessThan(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "<")
+
+ private fun lessThanOrEqual(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "<=")
+
+ private fun greaterThan(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, ">")
+
+ private fun greaterThanOrEqual(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, ">=")
+
+ private fun matches(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "matches")
+
+ private fun notMatches(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!matches")
+
+ private fun contains(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "contains")
+
+ private fun notContains(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!contains")
+
+ private fun startsWith(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "startswith")
+
+ private fun notStartsWith(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!startswith")
+
+ private fun endsWith(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "endswith")
+
+ private fun notEndsWith(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!endswith")
+
+ private fun assertIn(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "in")
+
+ private fun notIn(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!in")
+
+ private fun isType(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "is")
+
+ private fun isNotType(context: FunctionExecuteContext): Any? =
+ evaluateCondition(context, "!is")
+
+ private fun isNull(context: FunctionExecuteContext): Any? {
+ val expectedValue = context.firstParameter
+ val messageStr = tryGetMessageStr(context.secondParameter)
+
+ val result = ConditionEvaluator.evaluate(expectedValue, "notexists", null)
+ val functionName = context.functionName
+ if (!result) {
+ throw EvaluationAssertException(
+ "$functionName failed. Input value is not null. Value: $expectedValue$messageStr",
+ functionName,
+ expectedValue,
+ context.command.token.position
+ )
+ }
+
+ return null
+ }
+
+ private fun notNull(context: FunctionExecuteContext): Any? {
+ val expectedValue = context.firstParameter
+ val messageStr = tryGetMessageStr(context.secondParameter)
+
+ val result = ConditionEvaluator.evaluate(expectedValue, ConditionEvaluator.EXISTS, null)
+ val functionName = context.functionName
+ if (!result) {
+ throw EvaluationAssertException(
+ "$functionName failed. Input value is null. Value: $expectedValue$messageStr",
+ functionName,
+ expectedValue,
+ context.command.token.position
+ )
+ }
+
+ return null
+ }
+
+ private fun evaluateCondition(context: FunctionExecuteContext, condition: String): Nothing? {
+ val expectedValue = context.firstParameter
+ val actualValue = context.secondParameter
+ val messageStr = tryGetMessageStr(context.thirdParameter)
+
+ val result = when (condition) {
+ "==" -> deepEqual(expectedValue, actualValue)
+ "!=" -> !deepEqual(expectedValue, actualValue)
+ else -> ConditionEvaluator.evaluate(expectedValue, condition, actualValue)
+ }
+ val functionName = context.functionName
+ if (!result) {
+ throw ComparisonAssertException(
+ "$functionName failed. Expected: \n${toReadableString(expectedValue)}\nReceived: \n${
+ toReadableString(
+ actualValue
+ )
+ }\n$messageStr",
+ functionName,
+ expectedValue,
+ actualValue,
+ context.command.token.position
+ )
+ }
+
+ return null
+ }
+
+ /**
+ * Deep equality comparison that identifies objects and compares them
+ * ignoring the order of properties. Arrays are compared with order preserved.
+ */
+ private fun deepEqual(left: Any?, right: Any?): Boolean {
+ if (left == null && right == null) return true
+ if (left == null || right == null) return false
+
+ return when {
+ isJsonObject(left) && isJsonObject(right) ->
+ objectsEqualIgnoringPropertyOrder(toJsonNode(left), toJsonNode(right))
+ isJsonArray(left) && isJsonArray(right) ->
+ arraysEqual(toJsonNode(left) as ArrayNode, toJsonNode(right) as ArrayNode)
+ else -> ConditionEvaluator.equalish(left, right)
+ }
+ }
+
+ private fun isJsonObject(value: Any?): Boolean = when (value) {
+ is ObjectNode -> true
+ is JsonNode -> value.isObject
+ is Map<*, *> -> true
+ else -> false
+ }
+
+ private fun isJsonArray(value: Any?): Boolean = when (value) {
+ is ArrayNode -> true
+ is JsonNode -> value.isArray
+ else -> false
+ }
+
+ private fun toJsonNode(value: Any?): JsonNode = when (value) {
+ is JsonNode -> value
+ is Map<*, *> -> JsonConvert.convert(value)
+ else -> JsonConvert.convert(value)
+ }
+
+ private fun objectsEqualIgnoringPropertyOrder(left: JsonNode, right: JsonNode): Boolean {
+ if (!left.isObject || !right.isObject) return ConditionEvaluator.equalish(left, right)
+
+ val leftKeys = left.fieldNames().asSequence().toSet()
+ val rightKeys = right.fieldNames().asSequence().toSet()
+ if (leftKeys != rightKeys) return false
+
+ for (key in leftKeys) {
+ if (!deepEqual(left.get(key), right.get(key))) return false
+ }
+ return true
+ }
+
+ private fun arraysEqual(left: ArrayNode, right: ArrayNode): Boolean {
+ if (left.size() != right.size()) return false
+ for (i in 0 until left.size()) {
+ if (!deepEqual(left.get(i), right.get(i))) return false
+ }
+ return true
+ }
+
+ private fun toReadableString(value: Any?): String {
+ val valueStr = ConvertUtils.tryToString(value)
+ return when {
+ valueStr == null -> {
+ ""
+ }
+
+ valueStr.isEmpty() -> {
+ "\"\""
+ }
+
+ valueStr.isBlank() -> {
+ "\"$valueStr\""
+ }
+
+ else -> {
+ valueStr
+ }
+ }
+ }
+
+ private fun tryGetMessageStr(msg: Any?): String {
+ val message = ConvertUtils.tryToString(msg)
+ var messageStr = ""
+ if (message != null) {
+ messageStr = ". Additional message: $message"
+ }
+ return messageStr
+ }
+
+ private fun registerExtensionMethod(
+ context: TestOperationContext, name: String, method: (FunctionExecuteContext) -> Any?
+ ) {
+ context.registerExtensionMethod("$functionName.${name}") {
+ method(it)
+ }
+ }
+}
+
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt
new file mode 100644
index 0000000..a52b5e5
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/ComparisonAssertException.kt
@@ -0,0 +1,12 @@
+package com.intuit.isl.test.assertions
+
+import com.intuit.isl.utils.Position
+
+class ComparisonAssertException(
+ message: String,
+ functionName : String,
+ val expectedValue: Any?,
+ val actualValue: Any?,
+ position: Position? = null,
+ cause: Throwable? = null
+) : AssertException(message, functionName, position, cause)
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt
new file mode 100644
index 0000000..4264a71
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/assertions/EvaluationAssertException.kt
@@ -0,0 +1,11 @@
+package com.intuit.isl.test.assertions
+
+import com.intuit.isl.utils.Position
+
+class EvaluationAssertException(
+ message: String,
+ functionName : String,
+ val inputValue: Any?,
+ position: Position? = null,
+ cause: Throwable? = null
+) : AssertException(message, functionName, position, cause)
\ No newline at end of file
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/IslMockExecutor.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/IslMockExecutor.kt
new file mode 100644
index 0000000..60639e0
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/IslMockExecutor.kt
@@ -0,0 +1,17 @@
+package com.intuit.isl.test.mocks
+
+import com.intuit.isl.common.AsyncContextAwareExtensionMethod
+import com.intuit.isl.common.FunctionExecuteContext
+
+/**
+ * Represents a mock that runs compiled ISL code instead of returning a static value.
+ * When the mock is invoked, the runner is called with the same [FunctionExecuteContext]
+ * (parameters from the call), and its return value is used as the mock result.
+ */
+class IslMockExecutor(
+ private val runner: AsyncContextAwareExtensionMethod
+) {
+ suspend fun run(context: FunctionExecuteContext): Any? {
+ return runner(context)
+ }
+}
diff --git a/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt
new file mode 100644
index 0000000..704f09b
--- /dev/null
+++ b/isl-test/src/main/kotlin/com/intuit/isl/test/mocks/MockCaptureContext.kt
@@ -0,0 +1,7 @@
+package com.intuit.isl.test.mocks
+
+import com.fasterxml.jackson.databind.JsonNode
+
+class MockCaptureContext {
+ val captures = mutableListOf