Test your quests

Check that a quest pays, rejects and sends submissions to review as you expect, before any member tries it.

Before a quest reaches your members, your developer can check that it decides the way you expect. A good submission earns its reward, a bad one is rejected, and an unclear one goes to your team for review. These checks run on their computer, without your project, a sign-in or any member's data.

  • Catch mistakes early. A test fails when a quest stops deciding the way the test expects.
  • Rerun a real attempt. Take an attempt from the test environment and see whether new quest code would decide it differently.
  • Check your app's screens. Your developer can run your app against made-up members, quests and points, so a screen test shows the same thing every time.
  • Try it by hand instead. To click through a quest yourself, use test members in Console. No code needed.
  • Rehearse the whole campaign. Your developer or agent can play several test members over several days with the CLI; see test a campaign with test members.

The rest of this page is for your developer. @domino-sdk/relay/testing runs a quest's evaluator in your test runner the way Relay runs it when a member submits. You choose the evidence, the settings and what each capability answers; the kit returns the decision. No project, sign-in or network is involved.

Every starter with quests includes tests/quests.test.ts, which tests them with the kit. Run it with pnpm test.

Write a quest test

Import the quest and the kit

Import the quest's default export and the kit in a test file. Keep tests out of the quests/ discovery patterns, such as in tests/. The kit works in any runner: the starters use Vitest, and this page uses node:test.

Run the quest and assert the decision

runQuest evaluates the quest and resolves with its decision. facts answers the capabilities the evaluator reads, here an AI photo review:

import assert from "node:assert/strict"
import { test } from "node:test"
import {
  defineLedger,
  defineQuest,
  evaluators,
  manual,
  once,
  points,
  settings,
} from "@domino-sdk/relay/authoring"
import {
  expectAccept,
  expectReject,
  expectReview,
  runQuest,
} from "@domino-sdk/relay/testing"

const xp = defineLedger({ key: "xp", name: "XP" })
const showYourDesk = defineQuest({
  key: "show-your-desk",
  title: "Show us your desk",
  settings: settings.object({
    humanReview: settings.boolean({ label: "Send unclear photos to staff", default: true }),
  }),
  trigger: manual({ input: "photo" }),
  completion: once(),
  review: ({ settings }) => ({ enabled: settings.humanReview }),
  rewards: [points(xp, 30)],
  evaluate: evaluators.photo(({ settings }) => ({
    criteria: "A desk with a monitor or laptop is visible.",
    unclear: settings.humanReview ? "human-review" : "reject",
  })),
})

test("pays for a photo the review passes", async () => {
  const run = await runQuest(showYourDesk, {
    input: { photo: "evidence_desk" },
    facts: { review: { photo: { kind: "pass", reason: "A desk and a laptop" } } },
  })

  assert.deepEqual(expectAccept(run).rewards, [
    { kind: "points", balance: "xp", amount: 30 },
  ])
})

test("sends unclear photos to staff unless review is off", async () => {
  const input = { photo: "evidence_desk" }
  const facts = { review: { photo: { kind: "unclear" as const, reason: "Too dark" } } }

  expectReview(await runQuest(showYourDesk, { input, facts }), "Too dark")
  expectReject(
    await runQuest(showYourDesk, { input, facts, settings: { humanReview: false } }),
    "Too dark"
  )
})

Run your tests

pnpm test

A failed assertion names what the quest decided, such as Expected the quest to accept, but it rejected: "Too dark".

What runQuest does like Relay

A check is only useful if the kit decides the way Relay would. This table lists what it copies from a hosted evaluation. For the hosted behavior itself, see Evaluation time and randomness.

Hosted evaluationrunQuest
The release's setting values, rewards and review switchComputed from the quest with settings, as publication does. Omitted settings take their defaults.
ctx.now, Date.now(), new Date() frozen at the run's start; performance.now() returns 0Frozen at now, 2026-01-01T00:00:00Z unless you set it, and restored when the run ends.
Math.random(), crypto.getRandomValues(), crypto.randomUUID() seeded by the attemptSeeded by seed with the sandbox's generators, so one seed always draws the same values.
ctx.calendar counts days in the project's time zoneCounts days in timeZone, UTC unless you set it, or in the calendar time-zone changes you pass.
Capability requests and results are validatedThe same checks: an invalid request throws CapabilityError with code author-error, as hosted.
ctx.x and ctx.discord exist only for declared integrationsThe same. An image check of an X image needs the x integration declared.
At most 8 capability calls per evaluation run (the limit)The same: the ninth call throws CapabilityError with code capability-limit, and limits.capabilityCalls exports the limit.
Text and URL submissions are checked against the trigger's limitsThe same: a submission Relay refuses fails the run with a TestSetupError.
A review becomes a reject with requestedReview: true when human review is offThe same.
A custom reward selection can only reduce the published rewardsThe same: a larger or unpublished reward fails the run with Relay's message, such as Evaluator exceeded published reward bounds.

Because the clock and randomness are process-wide, runQuest runs one evaluation at a time, even when your runner runs tests concurrently, and fails an evaluation that does not finish within 15 seconds, as Relay does. Code running beside it in the same process sees the frozen clock too.

runQuest also refuses contexts Relay never builds: input for a quest that takes no evidence, and eligibleAt for a tracker-triggered quest, whose attempt is eligible when its record occurred.

Run options

OptionSets
settingsSetting values as an operator enters them in Console
inputThe submitted evidence: { value } for text and URL quests, { photo } (an evidence ID), { answers } for quizzes
trackerThe pinned observation of a tracker-triggered quest; build it with trackerObservation
performanceA content quest's performance stage: { metrics, observedAt?, rewards? }
factsFake capability answers (below)
memberThe member ID; defaults to the observation's member, else member_test
nowThe run's start time, ctx.now
eligibleAtWhen a manual quest's attempt was admitted; defaults to now. A tracker quest takes the observation's occurredAt
timeZoneThe project time zone ctx.calendar uses
calendarProject time-zone changes as { effectiveAt, timeZone }, oldest first
seedThe randomness seed; a hosted run uses the attempt ID

runQuest resolves with { decision, capabilityCalls, calls, release }. capabilityCalls lists every capability call with its sequence number, request and outcome, in order: { sequence, request, outcome }, where request.kind names the reader, such as "wallet-events" or "telegram", and the rest is the request as Relay received it. A later release can add kinds, so compare a request whole or read the fields of the kinds you expect. calls is deprecated: it leaves out Telegram, NFT, contract call, delegation, wallet event and transaction reads, and the member's state. release holds the rewards and review switch the run used, so a test can check what an accepted attempt grants beyond points, such as the group membership reward in the loyalty starter's tests.

Fake capability answers

facts is keyed like the evaluation context. Each answer is a value, or a function of the call's arguments:

import { CapabilityError, type TestFacts } from "@domino-sdk/relay/testing"

export const facts: TestFacts = {
  x: {
    connection: { userId: "7" },
    follows: ({ userId }) => userId === "42",
    post: () => {
      throw new CapabilityError("provider-unavailable", "X is unavailable")
    },
  },
  review: { text: { kind: "pass", reason: "Mentions the launch" } },
}
ReaderWithout an answer
member.identities, wallet.list, discord.membernull: the member has linked nothing
x.connectionnull: the member has not linked X
x.profile, x.follows, x.repostednull while x.connection is unset; required once you link an X account
wallet.balancenull while wallet.list is unset; required once you link a wallet on the read's chain
member.balances, member.completions, member.groups, member.campaigns, member.referralsRequired
x.post, discord.message, trackers, images, reviewRequired

The linked accounts are pinned for the run, as in a hosted run (Evaluator readers: null, errors and pinning has the hosted rules):

  • member.identities, wallet.list and x.connection answer once per run, even when you answer with a function.

  • Reads of the member's own accounts follow those pins. An answer for x.follows without x.connection, or for wallet.balance without wallet.list, fails the run: the member it describes has nothing linked. A balance read of an address the member never linked throws CapabilityError with code author-error, and an image check of { x: "profile" } without a linked X account throws one with code not-linked.

  • member.balances and member.completions hold the member's whole state, answered once per run. Give member.balances the member's balance in each ledger, as relay.me.balances() lists them, and member.completions every completion as { quest, key, completedAt } with the quest's key. ctx.member.balance(ledger) picks the ledger by key or ID and throws CapabilityError with code author-error for one you did not list. ctx.member.completions() filters by quest and since, puts the newest first and applies limit, as Relay does.

  • member.groups, member.campaigns and member.referrals are answered once per run the same way. Give member.groups the keys of the member's unarchived groups; ctx.member.groups() returns them in byte order. Give member.campaigns each campaign the project knows as { key, enrolled }; ctx.member.enrolled(campaign) returns its enrolled and throws CapabilityError with code author-error for a campaign you did not list. Give member.referrals the member's progress in each program, as relay.profiles.referral() returns it; ctx.member.referrals(program) picks the program by key or ID and throws author-error for one you did not list.

  • A call with no answer fails the run with a TestSetupError naming the fact to set, even if quest code catches errors.

  • Answers are checked against the result shapes Relay delivers, and quest code reads them as JSON. Relay never returns an empty wallet list, for example, so wallet: { list: [] } fails; use null.

  • Throw a CapabilityError to fail a call, as a provider outage does. If quest code lets it escape, runQuest rejects with it, where Relay would retry a retryable one later.

  • Anything else quest code throws rejects with an EvaluatorError: code evaluator-error, the thrown error's name and message, such as TypeError: Cannot read properties of undefined, and what quest code threw as cause. Relay fails the attempt once with the same code and message, without retrying. A failure you received and rethrow, or wrap with { cause: error }, still rejects as itself; throw new Error(error.message) does not. See quest code that fails on its own.

  • Functions receive the request as Relay does: a wallet balance query's chain is canonical ("solana" arrives as its CAIP-2 ID), and a tracker read has its defaults and asOf filled.

Tracker-triggered quests

Pass the observation that admitted the attempt. trackerObservation types data from the tracker, and trackerPage builds an answer for ctx.trackers.records:

import { test } from "node:test"
import {
  automatic,
  customSource,
  decision,
  defineLedger,
  defineQuest,
  defineTracker,
  points,
  settings,
  z,
} from "@domino-sdk/relay/authoring"
import {
  expectAccept,
  runQuest,
  trackerObservation,
  trackerPage,
} from "@domino-sdk/relay/testing"

const deposits = defineTracker({
  key: "deposits",
  title: "Deposits",
  source: customSource("bank", {
    identities: ["member"],
    data: z.object({ amount: z.number() }),
  }),
  window: { start: Date.parse("2026-01-01T00:00:00Z") },
})
const saver = defineQuest({
  key: "saver",
  title: "Save 100",
  settings: settings.object({}),
  trigger: automatic({ tracker: deposits }),
  completion: ({ keyed }) => keyed((ctx) => ctx.tracker.record.id),
  rewards: [points(defineLedger({ key: "xp", name: "XP" }), 10)],
  async evaluate(ctx) {
    const earlier = await ctx.trackers.records(deposits, { member: ctx.member.id })
    const total = earlier.items.reduce((sum, item) => sum + item.data.amount, 0)

    return total + ctx.tracker.record.data.amount >= 100
      ? decision.accept()
      : decision.reject({ reason: `Saved ${total} so far` })
  },
})

test("pays once the deposits reach 100", async () => {
  const run = await runQuest(saver, {
    tracker: trackerObservation(deposits, { data: { amount: 60 }, member: "member_1" }),
    facts: {
      trackers: { deposits: { records: trackerPage(deposits, [{ data: { amount: 40 } }]) } },
    },
  })

  expectAccept(run)
})

A fixed trackerPage answers every query. To check the query the evaluator sends, answer with a function and inspect its argument.

Assert decisions

HelperPasses when the runReturns
expectAccept(run)AcceptedThe decision, with rewards and completionKey
expectReject(run, reason?)Rejected, with a matching reason if givenThe decision
expectReview(run, reason?)Went to human reviewThe decision
expectDefer(run, reason?)DeferredThe decision
expectProgress(run, reason?)Recorded progressThe decision

reason is an exact string or a regular expression. The helpers throw a DecisionAssertionError, which every test runner reports as a failure.

A reject's code is on the returned decision: assert.equal(expectReject(run).code, "wrong-answers"). A code Relay would refuse, such as one with uppercase letters or one Domino reserves, rejects the run with an error naming the rule, so a test catches it before a deployment.

Read diagnostics

run.diagnostics lists what the evaluation did that had no effect, without failing the run. Each entry has a code and a message:

CodeWhat happened
completion-key-ignoredThe evaluator returned a completionKey that the quest's completion policy ignores. Only keyed(...) uses it; once(), streak(...), progress(...) and content quests key completions themselves.

To fail a test on any diagnostic, assert run.diagnostics is empty. Relay does not report diagnostics for hosted attempts, so tests are where they show up.

Replay a captured attempt

Relay captures what each evaluation run read: the release's settings and rewards, the attempt's input, frozen clock, seed and calendar, and every capability answer. replayAttempt runs your current quest code against such a capture, so you can check whether a change alters an outcome a member already got.

Fetch the capture

pnpm exec domino quests replay ATTEMPT_ID --environment test --out captures/ATTEMPT_ID.json --json

The command fetches the attempt's latest evaluation run, finds the quest among your discovered quests by its key, replays it and prints the result. --out also saves the capture for a test, in a file only you can read (mode 0600). Pass --quest <file> for a quest created in Console or kept outside discovery, and --capture <file> to replay a saved capture without a request. From code, management.attempts.capture(attempt) returns the same capture. Captures need publish permission, because they hold the providers' raw answers, such as X profiles and Discord messages. An attempt that has not been evaluated yet fails with 409 and code attempt-not-evaluated.

Replay it in a test

import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { test } from "node:test"
import { defineQuest, decision, manual, once, settings } from "@domino-sdk/relay/authoring"
import { replayAttempt } from "@domino-sdk/relay/testing"

const welcome = defineQuest({
  key: "welcome",
  title: "Welcome",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [],
  evaluate: async () => decision.accept(),
})

test("the welcome rewrite keeps the decision of attempt_123", async () => {
  const capture = JSON.parse(await readFile("captures/attempt_123.json", "utf8"))
  const replay = await replayAttempt(welcome, capture)

  assert.equal(replay.changed, false)
})

replayAttempt resolves with:

FieldHolds
decisionWhat the current code decides; null when the replay stopped at a divergence
recordedWhat quest code decided in the captured run, before Relay acted on it; null before it decided, and for runs decided before Relay kept decisions
changedWhether the decision differs from recorded: kind, reason, rewards or completion key; null when either is missing
capabilityDivergenceThe first call the capture could not answer, of any reader: its sequence, reason, the recorded request and the replayed one; null when it answered every call
capabilityCallsEvery capability call of the replay, with its answer, as in runQuest
divergence, callsDeprecated: the same, for the readers 1.0 had only. divergence is null when the first divergence is a call of a newer reader

A capture answers a call by its position, as a retry of the run does. A call is a divergence when the captured run made no such call (new), sent another request (changed), or got a retryable failure Relay did not keep (uncaptured). The replay stops there unless you pass facts, which answer those calls; capabilityDivergence still names the first one. A capture of another quest key fails with TestSetupError.

The capture's answers are checked as Relay delivered them, but a field or a value a later Relay added to a result passes as captured, so a capture keeps replaying after Relay's results grow. The answers you pass in facts are checked strictly, as in runQuest.

Call evaluate yourself

createTestContext(quest, options) builds the host context quest.evaluate takes, with the same fake readers, without freezing the clock or checking rewards. Use it to test helpers that take readers, or to drive quest.evaluate step by step.

A test that assembles its own context needs a ctx.member. Build it with fakeEvaluationMember, passing the methods your code reads:

import { fakeEvaluationMember } from "@domino-sdk/relay/testing"

const member = fakeEvaluationMember({
  id: "member_1",
  groups: async () => ["vip"],
})

The methods you leave out answer for a member with no linked accounts, completions, groups or enrollments; balance and referrals fail with a CapabilityError of code author-error, as Relay does for an unknown ledger or program. A minor release can give EvaluationMember a method, which this helper answers too, so a member written by hand can stop compiling while one from fakeEvaluationMember does not.

For ctx.wallet and ctx.telegram, fakeWalletReader(facts) and fakeTelegramReader(facts) build readers with every method Relay supplies, answered from facts.wallet and facts.telegram as runQuest answers them:

import {
  fakeEvaluationMember,
  fakeTelegramReader,
  fakeWalletReader,
} from "@domino-sdk/relay/testing"

// Readers for a context you pass to quest.evaluate yourself.
export const readers = {
  member: fakeEvaluationMember(),
  wallet: fakeWalletReader(),
  telegram: fakeTelegramReader({ telegram: { connection: { userId: "42" } } }),
}

Test your app's UI

createFakeRelay answers the participant API from data you give it, so a UI test of an app built on @domino-sdk/relay/react sees the same member, quests and points on every run, without a project or a sign-in.

Seed the fake

import { createFakeRelay } from "@domino-sdk/relay/testing"

const xp = { id: "ledger_xp", key: "xp", name: "XP" }
const relay = createFakeRelay({
  member: "member_ada",
  balances: [{ ledger: xp, balance: 120, available: 120 }],
})
OptionAnswers
memberGET /v1/auth/session: a session for this member ID, or null (the default) for a signed-out visitor
scopeThe session's organization, project and environment; defaults to org_test, project_test and test
quests, collections, entitlementsGET /v1/me/experience, as the client's quests.experience() reads it
balancesGET /v1/me/points
ledgersGET /v1/point-ledgers; defaults to the ledgers in balances
pointHistoryGET /v1/me/points/:ledger/history, keyed by ledger key or ID, in one page
activityGET /v1/me/activity, oldest event first, paged by cursor and limit
leaderboardsGET /v1/leaderboards/:board, keyed by board key or ID
badgesGET /v1/badges/catalog as { items } and GET /v1/badges as an array, signed in or out
routesAny other route, or a replacement for one above, keyed "METHOD /v1/path"; a :name segment matches any one segment

An omitted seed answers empty: no quests, no balances, no events. The fake also answers POST /v1/auth/logout, which signs the member out, and POST /v1/auth/exchange with a server integration key (di_...) as the bearer token, which signs in the seeded member, or a member whose ID is the subject when none is seeded.

  • Signed out, member reads answer 401 with code not-signed-in, as Relay does, and the session reads null.
  • Every answer is checked against the shape the SDK reads, so a seed Relay could never send, such as a balance of 1.5, throws a TestSetupError naming the route when you create the fake.
  • A participant route the fake does not answer fails loudly: 501 with code not-faked and the method and path in the message, so a test cannot pass on an empty answer. Answer it with routes, as for a submission. A path that is not a participant route answers 404 with code route-not-found, as the participant proxy does.
  • relay.requests lists the method and path of every request the fake answered, in order, so a test can check what the app read.

Paths answer the same with or without the /relay prefix that RelayProvider uses by default.

Route the app's requests to it

relay.handle(request) takes a Request and resolves with a Response. In Playwright, route the app's /relay/v1/ requests to it:

import { expect, test } from "@playwright/test"
import { createFakeRelay } from "@domino-sdk/relay/testing"

test("shows the member's points", async ({ page }) => {
  const relay = createFakeRelay({
    member: "member_ada",
    balances: [
      { ledger: { id: "ledger_xp", key: "xp", name: "XP" }, balance: 120, available: 120 },
    ],
  })

  await page.route("**/relay/v1/**", async (route) => {
    const request = route.request()
    const response = await relay.handle(
      new Request(request.url(), {
        method: request.method(),
        headers: request.headers(),
        body: request.postData() ?? undefined,
      })
    )

    await route.fulfill({
      status: response.status,
      headers: Object.fromEntries(response.headers),
      body: Buffer.from(await response.arrayBuffer()),
    })
  })

  await page.goto("/")
  await expect(page.getByText("120 XP")).toBeVisible()
})

If your app uses its own login, run your server's createParticipantBackend handler against relay.fetch instead, at a local origin such as http://127.0.0.1: see test the login in a browser.

In a component test with Vitest or node:test, pass relay.fetch to the client instead: <RelayProvider client={createRelayClient({ baseUrl: "https://app.test", fetch: relay.fetch })}>. With MSW, return relay.handle(request) from a handler.

What RelayProvider reads

RelayProvider reads nothing until it mounts. Then:

WhenRequests
On mountGET /v1/auth/session, twice: once directly and once before the experience
On mount, signed inGET /v1/me/experience, and GET /v1/me/activity?start=latest to find the newest event
Every 5 seconds, signed inGET /v1/me/activity?cursor=... for events after the last one (polling.activityMs)
Every 30 secondsGET /v1/me/experience when signed in, GET /v1/auth/session when signed out (polling.snapshotMs). A member read refused with not-signed-in reads the session again at once
On each activity eventThe reads of the hooks the event changes, such as GET /v1/me/points after points.awarded
On window focusThe activity feed again, or the session for a signed-out visitor, unless either was read in the last 2 seconds; queries with refetchOnFocus read again too

Each hook adds its own read when a component first uses it, such as useBalances (GET /v1/me/points), useActivity (GET /v1/me/activity) and useLeaderboard (GET /v1/leaderboards/:board). Reads pause while the tab is hidden.

useActivity reads the feed from its first event, so seeded activity reaches its data.items and onEvent. The provider's own feed starts at the newest event, so seeded events do not make it read hooks again. To test how the app reacts to a new event, answer GET /v1/me/activity with routes, returning the event once the cursor the first answer gave comes back.

Where next

On this page