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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .claude/unit-testing-jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Unit Testing Job Code

OpenFn job expressions are not valid JavaScript out of the box — top-level adaptor calls like `get('/endpoint')` prevent them from being imported directly into a test runner. Compiling them first solves this.

## Approach

1. Compile job expressions to standard JavaScript
2. Import compiled files in your test suite
3. Test any pure functions in isolation

## Compiling for Tests

`openfn compile` writes compiled output to `dist/` by default (as `.mjs` files). Use `--exports-only` to also strip adaptor operation calls, keeping only explicitly exported code.

```bash
# Compile all workflows in the project (full compilation, preserves operations)
openfn compile

# Compile all workflows, stripping operation calls (useful for unit testing)
openfn compile --exports-only

# Compile a single workflow by name
openfn compile my-workflow

# Compile a single workflow to a custom directory
openfn compile my-workflow -o tests/

# Compile a single job expression (prints to stdout)
openfn compile workflows/my-workflow/step-a.js --exports-only

# Watch mode: recompile whenever source files change
openfn compile --exports-only --watch

# Remove the output folder before compiling
openfn compile --exports-only --clean

# Compile a project in another directory
openfn compile --workspace path/to/project
```

## What `--exports-only` keeps

`--exports-only` strips operation calls. Only explicitly exported declarations survive:

- `export const myHelper = ...` ✓
- `export function parseSms() {}` ✓
- `const helper = ...` (not exported) ✗ — dropped
- `fn(state => ...)` (operation call) ✗ — stripped

`export default` is removed in strip mode — it is only needed by the runtime, not for unit testing.

Steps whose compiled output is empty after stripping are skipped — no file is written.

## Full compilation (no `--exports-only`)

Without `--exports-only`, the full compiled output is written — all declarations are preserved and operations are kept in `export default [op1, op2, ...]`:

```js
import { post } from '@openfn/language-http';

export default [post('/endpoint', { data: state.data })];
```

All steps are written regardless of whether they export anything.

## Example: Testing a Helper Function

**Source** (`workflows/dhis2-sync/transform.js`):

```js
import { dateFns } from '@openfn/language-dhis2';

export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd');

fn((state) => ({
...state,
data: state.data.map((row) => ({
...row,
date: formatDate(row.date),
})),
}));
```

**Compiled** (`dist/dhis2-sync/transform.mjs`) after `openfn compile --exports-only`:

```js
import { dateFns } from '@openfn/language-dhis2';

export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd');
```

The operation is stripped. `formatDate` survives because it is explicitly exported.

**Test** (using any test runner):

```js
// test/transform.test.js
import { formatDate } from '../dist/dhis2-sync/transform.mjs';

test('formats a date correctly', () => {
const result = formatDate(new Date('2024-01-15'));
assert.equal(result, '2024-01-15');
});
```

## Project-wide Compilation

Running `openfn compile` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`).

Output layout:

```
dist/
my-workflow/
step-a.mjs
step-b.mjs
another-workflow/
step-c.mjs
```

Override the output directory with `-o <dir>`:

```bash
openfn compile --exports-only -o tests/
```

Configure default directories in `openfn.yaml`:

```yaml
dirs:
workflows: workflows
compiled: dist # output dir used by openfn compile
```

## Recommended Setup

**`package.json`** (in your OpenFn project):

```json
{
"scripts": {
"compile": "openfn compile --exports-only",
"compile:watch": "openfn compile --exports-only --watch",
"test": "node --test test/**/*.test.js"
}
}
```

## Notes

- In `--exports-only` mode, only `export const` and `export function` declarations survive — non-exported helpers are always dropped
- Import statements are always preserved
- Without `--exports-only`, all code including operations is kept in `export default [...]`
- Watch mode reruns compilation on any source change, making the edit → test cycle fast
- Use `-O` to print compiled output to stdout instead of writing to disk
- Output files use the `.mjs` extension, so Node always treats them as ES modules — no `"type": "module"` needed in your project's `package.json`
- Use `--clean` to remove the output folder before compiling, and `--workspace <dir>` to target a project outside the current directory
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ The [.claude](.claude) folder contains detailed guides:

- **[event-processor.md](.claude/event-processor.md)** - Worker event processing deep-dive (ordering, batching) — companion to `packages/ws-worker/CLAUDE.md`
- **[yaml-formats.md](.claude/yaml-formats.md)** - v1 vs v2 project YAML formats: structure, detection logic, and conversion paths
- **[unit-testing-jobs.md](.claude/unit-testing-jobs.md)** - How to compile job for unit-testing

Key packages also carry their own `CLAUDE.md` (runtime, engine-multi, ws-worker), auto-loaded when you work in them.

Expand Down
8 changes: 8 additions & 0 deletions integration-tests/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @openfn/integration-tests-cli

## 1.0.26

### Patch Changes

- Updated dependencies [ac44559]
- @openfn/project@0.18.0
- @openfn/lightning-mock@2.4.24

## 1.0.25

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openfn/integration-tests-cli",
"private": true,
"version": "1.0.25",
"version": "1.0.26",
"description": "CLI integration tests",
"author": "Open Function Group <admin@openfn.org>",
"license": "ISC",
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# @openfn/cli

## 1.39.0

### Minor Changes

- ac44559: Improve `openfn compile` for unit-testing job code

- Add `--exports-only` to strip adaptor operation calls, keeping only exported declarations
- Compile whole projects (or a single workflow by name) to `dist/` as `.mjs` files
- Add `--clean` to remove the output folder before compiling
- Add `--workspace` support (as in `openfn execute` and `openfn project`)
- Support a `dirs.compiled` key in the workspace config (openfn.yaml) to set the output folder
- Watch mode now respects the configured workflows and output directories

### Patch Changes

- bec2581: Fix for cleaner output to stdout
- Updated dependencies [ac44559]
- @openfn/compiler@1.3.0
- @openfn/lexicon@2.4.0
- @openfn/project@0.18.0

## 1.38.5

### Patch Changes
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openfn/cli",
"version": "1.38.5",
"version": "1.39.0",
"description": "CLI devtools for the OpenFn toolchain",
"engines": {
"node": ">=18",
Expand Down Expand Up @@ -60,6 +60,7 @@
"@openfn/project": "workspace:^",
"@openfn/runtime": "workspace:*",
"chalk": "^5.6.2",
"chokidar": "^3.6.0",
"dotenv": "^17.3.1",
"dotenv-expand": "^12.0.3",
"figures": "^5.0.0",
Expand Down
55 changes: 47 additions & 8 deletions packages/cli/src/compile/command.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import yargs from 'yargs';
import { Opts } from '../options';
import * as o from '../options';
import * as po from '../projects/options';
import { build, ensure, override } from '../util/command-builders';

export type CompileOptions = Pick<
Opts,
| 'adaptors'
| 'command'
| 'expandAdaptors'
| 'exportsOnly'
| 'ignoreImports'
| 'expressionPath'
| 'planPath'
| 'logJson'
| 'log'
| 'outputPath'
Expand All @@ -19,48 +22,84 @@ export type CompileOptions = Pick<
| 'useAdaptorsMonorepo'
| 'globals'
| 'trace'
| 'watch'
| 'workflowName'
> & {
workflow?: Opts['workflow'];
repoDir?: string;
workspace?: string;
clean?: boolean;
};

const options = [
o.expandAdaptors, // order important
o.adaptors,
override(po.clean, {
description: 'Remove the output folder before compiling',
}),
o.exportsOnly,
o.ignoreImports,
o.inputPath,
o.log,
o.logJson,
override(o.outputStdout, {
default: true,
}),
o.outputStdout,
o.outputPath,
o.repoDir,
o.trace,
o.useAdaptorsMonorepo,
o.watchFlag,
o.workflow,
po.workspace,
];

const compileCommand: yargs.CommandModule<CompileOptions> = {
command: 'compile [path]',
describe:
'Compile an openfn job or workflow and print or save the resulting JavaScript.',
'Compile an openfn job, workflow, or whole project and print or save the resulting JavaScript.',
handler: ensure('compile', options),
builder: (yargs) =>
build(options, yargs)
.positional('path', {
describe:
'The path to load the job or workflow from (a .js or .json file or a dir containing a job.js file)',
demandOption: true,
'Path to a .js expression, .json/.yaml workflow, or a project directory. Omit to compile all workflows in the current project.',
})
.example(
'compile foo/job.js',
'Compiles the job at foo/job.js and prints the result to stdout'
)
.example(
'compile foo/workflow.json -o foo/workflow-compiled.json',
'Compiles the workflow at foo/work.json and prints the result to -o foo/workflow-compiled.json'
),
'Compiles the workflow and writes to the given path'
)
.example(
'compile',
'Compiles all workflows in the current project and writes JS files to dist/'
)
.example(
'compile my-workflow',
'Compiles a single workflow by name and writes JS files to dist/'
)
.example(
'compile my-workflow -O',
'Compiles a workflow and prints to stdout'
)
.example(
'compile foo/job.js --exports-only',
'Strips adaptor operation calls, keeping only exported declarations'
)
.example(
'compile --exports-only',
'Compiles entire project to dist/ stripping operation calls'
)
.example(
'compile --clean',
'Removes the output folder before compiling the project'
)
.example(
'compile foo/job.js --watch',
'Watches the file and recompiles on every change'
)
.example('compile --watch', 'Compiles all workflows on change'),
};

export default compileCommand;
Loading