watershed Collaborative data structures for Gleam

← watershed · Build guide / Step 06

Write repeatable tests

Turn the two-tab checks you’ve been running by hand into tests you can run anytime.

Surface sluice_js · start · settle · step

Run two clients in one test

The sluice lets a test create two documents and choose when each message arrives. Use it to repeat the two-tab checks without opening a browser or starting a server.

It plugs into the same client code as production, including encoding, queues, retries, reconnecting, and message order. It doesn’t open a real network connection or reproduce floodgate’s storage and login.

Choose when messages arrive

When a client submits something, it’s processed and given its place in the order right away — but the messages that result from it sit in a queue instead of arriving immediately. settle drains that queue until nothing’s left to deliver; step delivers exactly one message and stops. That splits “the order the server decided on” and “the order each client actually sees it in” into two separate things you control by hand.

So setting up a test room means: one sluice, two documents connecting to it, one settle to let both handshakes finish, the title and channels written on client A, and a second settle so client B can pick up that state from the shared root map.

test/convergence_test.gleam
fn room(
  name: String,
) -> #(
  Sluice,
  Document(document_schema.BoardDocument),
  Document(document_schema.BoardDocument),
  Channels,
  Channels,
) {
  let sluice = sluice_js.start(tenant: "default", document: name)
  let document_a = sluice_js.connect(sluice, "user-a")
  let document_b = sluice_js.connect(sluice, "user-b")
  sluice_js.settle(sluice)

  let root = watershed.root_typed(document_a)
  watershed.set_field(root, document_schema.title(), "Sprint retro")
  let assert Ok(notes) =
    watershed.create_or_map(document_a, or_map_kernel.RegisterMode)
  watershed.set_or_map_field(root, document_schema.notes(), notes)
  let assert Ok(votes) =
    watershed.create_or_map(document_a, or_map_kernel.TallyMode)
  watershed.set_or_map_field(root, document_schema.votes(), votes)
  sluice_js.settle(sluice)

  #(
    sluice,
    document_a,
    document_b,
    channels_of(document_a),
    channels_of(document_b),
  )
}

fn channels_of(document: Document(document_schema.BoardDocument)) -> Channels {
  let root = watershed.root_typed(document)
  let assert Ok(Some(notes)) =
    watershed.resolve_or_map_field(document, root, document_schema.notes())
  let assert Ok(Some(votes)) =
    watershed.resolve_or_map_field(document, root, document_schema.votes())
  Channels(notes:, votes:)
}

Neither settle call is decoration. Skip the second one, and client B is looking at a root map that doesn’t have those channels in it yet — so channels_of fails, not because anything is broken, but because you asked too early.

Assert on the board, not on the map

The tests read the board through board.snapshot_from_channels — the exact same function the UI renders from. That is deliberate. If the tests instead asserted directly on the raw map entries, they could pass while the actual board people look at was sorting its columns differently on each tab.

fn board_of(
  document: Document(document_schema.BoardDocument),
  channels: Channels,
) -> board.Snapshot {
  let root = watershed.root_typed(document)
  let assert Ok(Some(title)) =
    watershed.get_field(root, document_schema.title())
  let assert Ok(board) =
    board.snapshot_from_channels(title, channels.notes, channels.votes)
  board
}

This is exactly why board keeps the write functions with the board state: if a test reimplemented the write instead of calling the real one, all it would prove is that the copy agrees with itself.

Field note Extract pure modules; test without a server ↓

Both races, written as tests

The add race checks two things: board_a |> should.equal(board_b) confirms both tabs ended up showing the identical board, and the note count, two lookups, and column tally confirm they didn’t end up identical by both losing the same note.

pub fn concurrent_adds_keep_both_notes_test() -> Nil {
  let #(sluice, document_a, document_b, a, b) = room("retro-tutorial-adds")

  let first =
    board.add_note(
      a.notes,
      "user-a",
      "deploys got faster",
      board.WentWell,
      1000,
      1,
    )
  let second =
    board.add_note(
      b.notes,
      "user-b",
      "standup stayed short",
      board.WentWell,
      1000,
      1,
    )
  sluice_js.settle(sluice)

  let board_a = board_of(document_a, a)
  let board_b = board_of(document_b, b)

  board_a |> should.equal(board_b)
  board.note_count(board_a) |> should.equal(2)
  let assert Ok(_) = board.find_card(board_a, first)
  let assert Ok(_) = board.find_card(board_a, second)
  board.cards_for(board_a, board.WentWell)
  |> list.length
  |> should.equal(2)
}

The vote race sends three votes across two clients and checks they settle at +1. Same shape, same settle call, and no sleep anywhere in sight — the test doesn’t wait and hope the timing works out, it calls settle and gets the same answer every single time.

pub fn concurrent_plus_plus_minus_votes_settle_at_plus_one_test() -> Nil {
  let #(sluice, document_a, document_b, a, b) = room("retro-tutorial-votes")

  let id =
    board.add_note(
      a.notes,
      "user-a",
      "ship week went smoothly",
      board.WentWell,
      1000,
      1,
    )
  sluice_js.settle(sluice)

  board.upvote(a.votes, id)
  board.upvote(b.votes, id)
  board.downvote(b.votes, id)
  sluice_js.settle(sluice)

  let board_a = board_of(document_a, a)
  let board_b = board_of(document_b, b)

  board_a |> should.equal(board_b)
  let assert Ok(card) = board.find_card(board_a, id)
  card.votes |> should.equal(1)
}

Assert on the specific outcome, not just equality. A test that only checks both tabs end up looking the same can still pass while the thing users actually care about is broken — for example, if both tabs agreed by losing the same note. These two tests each check the specific outcome that matters: nothing you type disappears, and nothing you vote is lost.

Deepening: script the intermediate state

When you want to inspect a board mid-flight — one note delivered, the other still waiting — step advances one message at a time. It doesn’t change the order things were already assigned; the order you called the submit functions in decides that.

// Two adds, then deliver one message at a time instead of all at once.
board.add_note(a.notes, "user-a", "deploys got faster", board.WentWell, 1000, 1)
board.add_note(b.notes, "user-b", "standup stayed short", board.WentWell, 1000, 1)

sluice_js.step(sluice)     // deliver exactly one queued message
// ... assert the half-delivered board here ...
sluice_js.settle(sluice)   // then deliver everything else that's left

More controls. Both sluice and sluice_js provide pause / resume to hold back one client’s messages specifically, and advance(ms) to move the sluice’s logical clock forward. On sluice_js, advancing also fires every timer that became due. Hand its scheduler to presence_js.start_with_scheduler and presence runs on that clock too, so heartbeat and expiry tests advance without waiting on wall time. The JavaScript version also exposes step_info with details about each delivery, which is how the demo back on step 03 draws its log of what happened.

Field note Test client death deterministically ↓

The finished project

Six sheets, one source tree. Everything the procedure built, in the order it built it:

retro_tutorial_lustre/
├── gleam.toml                      dependencies and the javascript target
├── package.json                    pnpm build / pnpm serve
├── pnpm-workspace.yaml
├── build.mjs                       esbuild bundle for the browser
├── index.html                      mount point and all of the styling
├── src/
│   ├── retro_tutorial_lustre.gleam every sheet · connection, update, view
│   └── retro_tutorial_lustre/
│       ├── document_schema.gleam   step 01 · title, notes, votes
│       ├── note.gleam              step 02 · the note record and its codec
│       └── board.gleam             steps 02 and 04 · pure canonical snapshot and shared writes
└── test/
    ├── retro_tutorial_lustre_test.gleam  the gleeunit entry point
    ├── note_codec_test.gleam       round trips, including malformed payloads
    ├── board_test.gleam            ordering, tally joins, unfiled notes
    └── convergence_test.gleam      this sheet · two races and two wrong-mode guards

Every snippet in this guide that comes from the project is quoted from these files at build time, so what you read here and what compiles cannot drift. The complete source is in examples/retro_tutorial_lustre, and it is meant to be copied out and renamed.

cd examples/retro_tutorial_lustre
gleam test

Where to go when this board gets too small

The tutorial stops on purpose. It has no sequences, no drag and drop, no cross-column moves, no edit or delete, and no vote budgets — every one of those is a genuinely harder problem, and adding them here would have made the board harder to learn from, not more useful as an example.

The advanced retro board is the same idea with the hard parts left in: five channels, ordered columns on a sequence, cross-channel moves that render honestly rather than pretending to be atomic, and edit and delete flows. It is the next thing to read.

Beyond it, the example gallery maps this loop onto fourteen complete browser applications, and the field atlas has a page per structure with its merge rule and its best-fit uses.

You can now

Run gleam test and get 10 passed, no failures in about a second: two convergence races, two wrong-mode guards, the board’s ordering and joins, and the note codec — no server, no browser, no timing luck.

How the examples do it

Each note connects an implementation practice to a checked-in example. Open it for the code and reasoning.

Extract pure modules; test without a server

Move decisions into pure modules so most tests need no document, sluice, or server.

The triptych separates decisions from effects. Its protocol, scenario state, and guards run as ordinary unit tests. The small refresh guard below uses a generation counter to discard an old refresh after a newer one arrives.

Convergence and browser tests still cover behavior that needs a runtime. They stay small because pure tests cover the decision branches.

src/grocery_triptych_lustre/refresh_guard.gleam
pub type State {
  State(current_generation: Int, pending: Bool)
}

pub fn idle() -> State {
  State(current_generation: 0, pending: False)
}

pub fn request(state: State) -> #(State, Int) {
  let generation = state.current_generation + 1

  #(State(current_generation: generation, pending: True), generation)
}

pub fn flush(state: State, generation: Int) -> #(State, Bool) {
  case state.pending && state.current_generation == generation {
    True -> #(
      State(current_generation: state.current_generation, pending: False),
      True,
    )
    False -> #(state, False)
  }
}
Grocery triptych

The check. Six test files (protocol, scenario state, guards, actions) run pure, alongside one sluice convergence file and the smoke tier.

Demonstrated by Grocery triptych Source ↗

Test client death deterministically

Test a client dying mid-job with an in-process disconnect that produces the same leave event as the server.

The work queue promises to recover when a client disappears. If a worker dies, the job returns to the queue. If the dispatcher dies, the next client takes over. An automated test needs to prove both transitions.

sluice_js.disconnect produces the same leave event that the server would, so the test runs in process and on demand. A live smoke test covers the remaining boundary: whether floodgate notices a vanished socket and sends that event.

test/queue_semantics_test.gleam
pub fn held_job_returns_to_queue_when_holder_disconnects_test() {
  let #(sluice, document_a, document_b) = room("wq-worker-dies")
  let queue_a = queue_of(document_a)
  let queue_b = queue_of(document_b)
  let payload = job("doomed")

  watershed.ordered_add(queue_a, payload)
  sluice_js.settle(sluice)

  let events_b = queue_events(queue_b)
  let #(outcomes_a, id_a) = outcome_cell(queue_a)
  sluice_js.settle(sluice)
  transport_js.get_cell(outcomes_a)
  |> should.equal([AcquiredItem(id_a, payload)])

  // The tab holding the job goes away without completing or releasing.
  sluice_js.disconnect(sluice, document_a)
  sluice_js.settle(sluice)

  transport_js.get_cell(events_b)
  |> list.contains(ordered_collection_kernel.Added(payload, False, False))
  |> should.be_true()
  watershed.ordered_queue(queue_b) |> should.equal([payload])
  watershed.ordered_jobs(queue_b) |> should.equal([])
}
Work queue

The check. The same harness asserts a dispatcher promotion arrives as a queue event, not an assignment, pinning the event shape of recovery rather than just its outcome.

Demonstrated by Work queue Source ↗