Flow SDK

Testing Artifacts

Write unit and integration tests for SDK artifacts — mock NodeContext, assert outputs, test error paths, and run suites from CI.

Every artifact should have a test file. Tests live in the tests/ directory alongside artifacts and run against a real execution sandbox with configurable mocking. Manage tests at SDK → Artifacts → [artifact] → Tests.

Test File Structure

tests/enrich-incident.test.ts
import { describe, it, expect, beforeEach } from '@flowos/sdk/testing'
import { mockContext } from '@flowos/sdk/testing'
import { run } from '../artifacts/enrich-incident'

describe('enrich-incident', () => {
  let ctx: ReturnType<typeof mockContext>

  beforeEach(() => {
    ctx = mockContext()
  })

  it('enriches a P1 incident with CI count and risk score', async () => {
    // Arrange
    ctx.tables.find.mockResolvedValue({
      id: 'inc_01HX',
      severity: 'P1',
      cmdb_items: ['ci_01', 'ci_02', 'ci_03'],
      custom_fields: {},
    })
    ctx.tables.update.mockResolvedValue({ id: 'inc_01HX' })

    // Act
    const result = await run(ctx, { incidentId: 'inc_01HX', enrichmentLevel: 'full' })

    // Assert
    expect(result.enriched).toBe(true)
    expect(result.ciCount).toBe(3)
    expect(result.riskScore).toBe(80)   // 3 * 10 + 50 (P1 bonus)
    expect(ctx.tables.update).toHaveBeenCalledWith('inc_01HX', expect.objectContaining({
      custom_fields: { risk_score: 80 },
    }))
  })

  it('returns lower risk score for P2 incidents', async () => {
    ctx.tables.find.mockResolvedValue({
      id: 'inc_02HX',
      severity: 'P2',
      cmdb_items: ['ci_01'],
      custom_fields: {},
    })
    ctx.tables.update.mockResolvedValue({ id: 'inc_02HX' })

    const result = await run(ctx, { incidentId: 'inc_02HX', enrichmentLevel: 'basic' })
    expect(result.riskScore).toBe(30)   // 1 * 10 + 20 (non-P1 bonus)
  })

  it('throws if incident not found', async () => {
    ctx.tables.find.mockRejectedValue(new Error('Record not found'))
    await expect(run(ctx, { incidentId: 'nonexistent', enrichmentLevel: 'basic' }))
      .rejects.toThrow('Record not found')
  })
})

mockContext API

mockContext() returns a fully-typed mock NodeContext where every method is a jest.fn() / vitest mock. You control the return values per test.

typescript
import { mockContext } from '@flowos/sdk/testing'
const ctx = mockContext()

// Mock table methods
ctx.tables.find.mockResolvedValue({ id: 'inc_01HX', title: 'Test', status: 'open' })
ctx.tables.query.mockResolvedValue({ data: [/* ... */], total: 1, page: 1, pageSize: 50 })
ctx.tables.create.mockResolvedValue({ id: 'new_id' })
ctx.tables.update.mockResolvedValue({ id: 'updated_id' })
ctx.tables.delete.mockResolvedValue(undefined)
ctx.tables.sql.mockResolvedValue([{ count: 5 }])

// Mock HTTP
ctx.http.get.mockResolvedValue({ status: 200, body: { data: [] } })
ctx.http.post.mockResolvedValue({ status: 201, body: { id: 'ext-123' } })

// Mock secrets
ctx.secrets.get.mockResolvedValue('test-api-key')

// Mock notifications (fire-and-forget — usually just verify called)
ctx.notify.send.mockResolvedValue({ notificationId: 'notif_01' })

// Mock events
ctx.events.publish.mockResolvedValue({ eventId: 'evt_01' })

// Mock cache
ctx.cache.get.mockResolvedValue(null)
ctx.cache.set.mockResolvedValue(undefined)

// Assert on calls
expect(ctx.tables.update).toHaveBeenCalledWith('incidents', 'inc_01HX', { status: 'resolved' })
expect(ctx.notify.send).toHaveBeenCalledTimes(1)

Setting Trigger Payload

typescript
const ctx = mockContext({
  trigger: {
    type: 'record_event',
    payload: {
      table: 'incidents',
      action: 'created',
      record: { id: 'inc_01HX', severity: 'P1', status: 'open' },
      previous: null,
    },
  },
  user: { id: 'usr_01HX', email: 'alice@acme.com', name: 'Alice' },
  workspace: { id: 'ws_01HX', name: 'Acme IT', slug: 'acme-it', timezone: 'UTC' },
})

Integration Tests

Integration tests run against a real sandbox environment with actual table reads/writes (isolated to the test environment). Use the integrationContext factory:

typescript
import { integrationContext } from '@flowos/sdk/testing'

// Uses test environment — real DB, real secrets
const ctx = await integrationContext({ environment: 'development' })

it('creates an incident end-to-end', async () => {
  const incident = await ctx.tables.create('incidents', {
    title: '[Test] Integration test incident',
    severity: 'P4',
    status: 'open',
    tags: ['test'],
  })

  expect(incident.id).toBeTruthy()

  // Clean up after test
  await ctx.tables.delete('incidents', incident.id)
})
Integration tests always run in the development environment. They cannot target staging or production. Records created by tests are not automatically cleaned up — always delete them in an afterEach or afterAll block.

Running Tests

From the UI: SDK → Artifacts → [artifact] → Tests tab → Run All

From CLI:

bash
# Run all tests in the workspace
flowos test run

# Run tests for a specific artifact
flowos test run --artifact enrich-incident

# Run a specific test file
flowos test run --file tests/enrich-incident.test.ts

# Run in watch mode (re-runs on file save)
flowos test run --watch

# Run integration tests (requires dev environment token)
flowos test run --integration --env development

# CI mode — outputs JUnit XML
flowos test run --reporter junit --output test-results.xml

Coverage

bash
flowos test coverage
# Outputs:
# enrich-incident.ts      92.3%  (branches: 85.7%)
# github-push-handler.ts  78.1%  (branches: 70.0%)
# daily-sla-report.ts    100.0%  (branches: 100.0%)

Coverage reports are also visible in the artifact detail page under the Tests tab.