Skip to content

Testing

@korajs/test provides a testing harness for verifying sync, conflict resolution, and multi-device behavior. It creates virtual device networks with real SQLite stores and in-memory transports. No actual network required.

Installation

bash
pnpm add -D @korajs/test

The package depends on korajs and better-sqlite3 for local stores. It is designed for use with Vitest.

Creating a Test Network

createTestNetwork() sets up a virtual server and multiple devices, all connected via in-memory transports:

typescript
import { defineSchema, t } from '@korajs/core'
import { createTestNetwork } from '@korajs/test'
import { afterEach, describe, test, expect } from 'vitest'

const schema = defineSchema({
  version: 1,
  collections: {
    todos: {
      fields: {
        title: t.string(),
        completed: t.boolean().default(false),
      },
    },
  },
})

describe('sync tests', () => {
  let network

  afterEach(async () => {
    if (network) {
      await network.close()
      network = null
    }
  })

  test('data syncs between devices', async () => {
    network = await createTestNetwork(schema)
    const [deviceA, deviceB] = network.devices

    // Insert on device A
    await deviceA.collection('todos').insert({ title: 'Buy milk' })

    // Sync both devices through the server
    await deviceA.sync()
    await deviceB.sync()

    // Device B has the record
    const todos = await deviceB.getState('todos')
    expect(todos).toHaveLength(1)
    expect(todos[0].title).toBe('Buy milk')
  })
})

Custom Device Count

By default, createTestNetwork creates 2 devices. You can specify more:

typescript
network = await createTestNetwork(schema, { devices: 3 })

const [alice, bob, charlie] = network.devices

Or use custom names:

typescript
network = await createTestNetwork(schema, {
  deviceNames: ['alice', 'bob', 'charlie'],
})

Server-Side Network Options

createTestNetwork also accepts options that configure the virtual server:

typescript
import type { OperationValidator } from '@korajs/server'

const validateOperation: OperationValidator = (op) => {
  // Reject operations the server should not accept. A rejected outbound
  // operation is surfaced on the originating device via getRejectedOperations().
  if (op.collection === 'todos' && op.type === 'insert') {
    return { action: 'reject', code: 'FORBIDDEN', message: 'inserts are blocked' }
  }
  return { action: 'accept' }
}

network = await createTestNetwork(schema, {
  validateOperation,   // adjudicate untrusted client operations before materialization
  blobStorage: true,   // enable central blob storage on the server (persist and serve uploaded blob bytes)
})
OptionTypeDescription
validateOperationOperationValidatorAdjudicate untrusted client operations on the server before materialization. Operations the server rejects are surfaced on the originating device through getRejectedOperations().
blobStoragebooleanEnable central blob storage on the server, which persists and serves uploaded blob bytes.

Testing Offline Behavior

Each device can disconnect and reconnect independently:

typescript
test('offline mutations sync after reconnect', async () => {
  network = await createTestNetwork(schema)
  const [deviceA, deviceB] = network.devices

  // Sync, then disconnect device A
  await deviceA.sync()
  await deviceA.disconnect()

  // Insert while offline
  await deviceA.collection('todos').insert({ title: 'Offline todo' })

  // Reconnect and sync
  await deviceA.reconnect()
  await deviceB.sync()

  const todos = await deviceB.getState('todos')
  expect(todos).toHaveLength(1)
  expect(todos[0].title).toBe('Offline todo')
})

Asserting Convergence

Use expectConverged() to verify all devices have identical state:

typescript
import { createTestNetwork, expectConverged } from '@korajs/test'

test('all devices converge', async () => {
  network = await createTestNetwork(schema, { devices: 3 })
  const [a, b, c] = network.devices

  // Each device inserts a record
  await a.collection('todos').insert({ title: 'From A' })
  await b.collection('todos').insert({ title: 'From B' })
  await c.collection('todos').insert({ title: 'From C' })

  // Sync all devices (multiple rounds for relay)
  await a.sync()
  await b.sync()
  await c.sync()
  await a.disconnect()
  await b.disconnect()
  await a.sync()
  await b.sync()

  // All devices should have identical state
  await expectConverged(network.devices, schema)
})

For more details without throwing, use checkConvergence():

typescript
import { checkConvergence } from '@korajs/test'

const result = await checkConvergence(network.devices, schema)
if (!result.converged) {
  console.log(result.differences)
  // Shows which collections differ, missing records, and field-level differences
}

Testing Updates and Deletes

typescript
test('updates sync between devices', async () => {
  network = await createTestNetwork(schema)
  const [deviceA, deviceB] = network.devices

  const record = await deviceA.collection('todos').insert({ title: 'Original' })
  await deviceA.sync()
  await deviceB.sync()

  // Update on A
  await deviceA.collection('todos').update(record.id, { title: 'Updated' })
  await deviceA.disconnect()
  await deviceA.sync()
  await deviceB.disconnect()
  await deviceB.sync()

  const todos = await deviceB.getState('todos')
  expect(todos[0].title).toBe('Updated')
})

test('deletes sync between devices', async () => {
  network = await createTestNetwork(schema)
  const [deviceA, deviceB] = network.devices

  const record = await deviceA.collection('todos').insert({ title: 'To delete' })
  await deviceA.sync()
  await deviceB.sync()

  await deviceA.collection('todos').delete(record.id)
  await deviceA.disconnect()
  await deviceA.sync()
  await deviceB.disconnect()
  await deviceB.sync()

  const todos = await deviceB.getState('todos')
  expect(todos).toHaveLength(0)
})

Inspecting Merge Decisions

TestDevice wires audit persistence the same way production apps do, so every merge decision made during a harness test is persisted to the _kora_audit_traces table in that device's store. You can query it directly to assert on how a conflict was resolved:

typescript
const traces = await deviceA.store.getAuditTraces({ collections: ['todos'] })
expect(traces.length).toBeGreaterThan(0)

TestDevice API

Each device in the network exposes:

MethodDescription
.collection(name)Access a collection for insert/update/delete/query
.sync()Connect to the server and sync
.disconnect()Close the sync connection
.reconnect()Re-establish the sync connection
.getState(collection)Get all records in a collection (for assertions)
.getNodeId()Get the device's unique node ID
.getVersionVector()Get the device's version vector
.isConnected()Check if currently connected
.getRejectedOperations()Async: get the operations the server rejected for this device that have not been reconciled (empty when the device has never connected)
.close()Release all resources