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.gleamfn 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.
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.
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.gleampub 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)
}
} 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.gleampub 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([])
} 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 ↗