cw

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package cw is the scenario API of module chatwright.dev/runtime: a framework- and language-agnostic testing harness for conversational applications.

A scenario is written once against platform-neutral verbs — send a text, expect a message, expect an action — and Chatwright maps them onto a concrete platform (Telegram today, WhatsApp next). It emulates that platform's API server, which owns delivering updates to the bot-under-test (over a real HTTP webhook, or via getUpdates long-polling on platforms that support it) and captures the API calls the bot makes back. The bot under test may be written in any language or framework — Chatwright only speaks HTTP.

Typical use:

w := cw.New(t) // defaults to Telegram
// Configure the bot-under-test to use w.BotAPIURL() as its platform API,
// then hand Chatwright its webhook handler (any http.Handler):
w.ServeWebhook(myBot.WebhookHandler())

chat := w.PrivateChat(cw.User{ID: "alice", FirstName: "Alice"})
chat.SendText("/start")
chat.ExpectBotMessage().Within(time.Second).Text("Howdy stranger")

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Action

type Action struct {
	// contains filtered or unexported fields
}

Action is a platform-neutral assertion handle for an interactive action.

func (*Action) Click

func (a *Action) Click() *Chat

Click activates the action, sending the appropriate event back to the bot: a callback for actions with an ID (Telegram callback query / WhatsApp interactive reply), or the action's label as text otherwise. Returns the chat so a reply can be asserted next.

func (*Action) CopyText added in v0.1.2

func (a *Action) CopyText(want string) *Action

CopyText asserts the text copied by a platform-native copy action.

func (*Action) ID

func (a *Action) ID(want string) *Action

ID asserts the action's stable identifier.

func (*Action) InlineQuery added in v0.6.0

func (a *Action) InlineQuery(want string) *Action

InlineQuery asserts that this is a platform-native inline-mode action and that it prefills want.

func (*Action) Label

func (a *Action) Label(want string) *Action

Label asserts the action's user-visible label.

type BotMessage

type BotMessage struct {
	// contains filtered or unexported fields
}

BotMessage is a fluent handle to a message the bot sent. Assertion methods (Text, IsTextMessage, ExpectAction, ...) block until the message arrives — up to the harness's safety timeout (see WithSafetyTimeout), regardless of any Within budget — and fail the test if it never does. They are platform-neutral.

func (*BotMessage) ExpectAction

func (m *BotMessage) ExpectAction(row, col int) *Action

ExpectAction returns a platform-neutral handle to the interactive action (button) at (row, col). Use Label and ID — these map onto each platform's native representation (Telegram button text/callback_data, WhatsApp reply title/id).

func (*BotMessage) ExpectEdited

func (m *BotMessage) ExpectEdited() *BotMessage

ExpectEdited returns a fluent handle that waits for this message to be edited in place (e.g. a Telegram editMessageText call) and asserts on its new content — the same assertion methods as ExpectBotMessage, but bound to this message's identity rather than to the next outbound message. Like ExpectBotMessage, it waits up to the harness's safety timeout and starts with no latency budget of its own; add one with Within.

func (*BotMessage) IsTextMessage

func (m *BotMessage) IsTextMessage() *BotMessage

IsTextMessage asserts the bot's message carries text, returning the handle for further assertions.

func (*BotMessage) Metrics

func (m *BotMessage) Metrics() Metrics

Metrics returns the metrics captured for this message.

func (*BotMessage) Snapshot

func (m *BotMessage) Snapshot() platform.Message

Snapshot returns an immutable observation of the resolved bot message. Nested action rows are detached from the emulator's mutable message state, so callers can inspect transport output without changing later assertions.

func (*BotMessage) Text

func (m *BotMessage) Text(want string) *BotMessage

Text asserts the bot's message text equals want.

func (*BotMessage) TextContains

func (m *BotMessage) TextContains(substr string) *BotMessage

TextContains asserts the bot's message text contains substr.

func (*BotMessage) TextMatches

func (m *BotMessage) TextMatches(pattern string) *BotMessage

TextMatches asserts the bot's message text matches the given regular expression (as accepted by the regexp package). An invalid pattern fails the test immediately rather than silently matching nothing.

func (*BotMessage) Within

func (m *BotMessage) Within(d time.Duration) *BotMessage

Within sets the latency budget a reply is judged against: once a reply arrives, if it took longer than d, the test fails showing the observed latency and the reply's actual text. Within does NOT shorten how long Chatwright waits for that reply to arrive in the first place — that ceiling is the harness's safety timeout (default 5s; see WithSafetyTimeout), independent of d, so a late-but-arrived reply is a diagnostic failure (expected/actual text, observed latency) rather than an opaque "none arrived" timeout. If d exceeds the configured safety timeout, the wait is extended to d so a generous budget is never undercut by it.

Calling Within after the message has already resolved (e.g. after Text or IsTextMessage) is a usage error: the wait already happened, so a budget set now can no longer be honored. It fails the test immediately with a clear message instead of silently doing nothing.

type BranchEvidence

type BranchEvidence struct {
	Name           string
	InvocationPath InvocationPath
	Source         SourceReference
}

BranchEvidence records a branch declared directly by one invocation.

type Chat

type Chat struct {
	// contains filtered or unexported fields
}

Chat is a conversation between a user and the bot-under-test. Obtain one via Chatwright.PrivateChat, then drive it with SendText and assert with ExpectBotMessage. Its methods are platform-neutral.

func (*Chat) ExpectBotMessage

func (c *Chat) ExpectBotMessage() *BotMessage

ExpectBotMessage asserts that the bot sends a message to this chat, returning a fluent handle to assert on its content. Chatwright waits up to the harness's safety timeout (default 5s; see WithSafetyTimeout) for it to arrive; narrow the latency this is judged against — without shortening that wait — with Within.

func (*Chat) ExpectNoMessage

func (c *Chat) ExpectNoMessage(within time.Duration)

ExpectNoMessage asserts that the bot does not send a new message to this chat within the given window. It fails the test if one arrives, reporting its text. Unlike ExpectBotMessage, it does not consume a slot in the chat's message cursor: a subsequent ExpectBotMessage still waits for the next unconsumed message.

func (*Chat) SendInlineQuery added in v0.6.0

func (c *Chat) SendInlineQuery(query string) *InlineQuery

SendInlineQuery delivers the user's inline query to a platform that supports it and returns a lazy answer handle.

func (*Chat) SendText

func (c *Chat) SendText(text string) *Chat

SendText delivers a text message from the user to the bot-under-test. The emulator builds the platform-native update and delivers it — over the bot's webhook, or by queuing it for getUpdates on platforms that support polling — Chatwright itself never touches the wire.

func (*Chat) SubmitCallback added in v0.1.2

func (c *Chat) SubmitCallback(messageID int, callbackData string) *Chat

SubmitCallback is a developer-level escape hatch for protocol robustness scenarios. Unlike Action.Click it deliberately does not require callbackData to be present on the current keyboard, allowing tests to submit a stale, forged, or otherwise invalid callback against an exact bot message.

Product-facing actor loops must continue to use advertised actions only.

type Chatwright

type Chatwright struct {
	// contains filtered or unexported fields
}

Chatwright is a single test's conversational world: an emulated platform API server plus the wiring to attach the bot-under-test's webhook (when it has one). The emulator — not Chatwright — owns building updates, assigning them identity, and delivering them; Chatwright only submits neutral actions.

func New

func New(t testing.TB, opts ...Option) *Chatwright

New starts a Chatwright harness. It selects a platform (Telegram by default, override with OnPlatform), boots that platform's emulated API server, and registers cleanup with the test. Configure the bot-under-test with BotAPIURL, then attach its webhook via ServeWebhook or WebhookAt — or, on platforms that support it (Telegram), leave neither set and run the bot's own getUpdates polling loop against BotAPIURL instead.

func (*Chatwright) BotAPIURL

func (cw *Chatwright) BotAPIURL() string

BotAPIURL is the base URL the bot-under-test must use as its platform API host, in place of the real one. Every call the bot makes there is captured.

func (*Chatwright) Platform

func (cw *Chatwright) Platform() string

Platform is the name of the active platform, e.g. "telegram".

func (*Chatwright) PrivateChat

func (cw *Chatwright) PrivateChat(u User) *Chat

PrivateChat returns the private chat between the given user and the bot. Calling it again for the same user returns the same *Chat handle, not a fresh one: the consumption cursor (which bot messages have already been asserted on) and lastSent latency baseline are shared across every call site that asks for that user's chat, matching Telegram's one chat per user.

func (*Chatwright) ServeWebhook

func (cw *Chatwright) ServeWebhook(h http.Handler)

ServeWebhook runs the given handler as the bot-under-test's webhook on a local HTTP server, so updates are delivered over real HTTP. Use this for in-process bots. The server is shut down when the test ends.

func (*Chatwright) WebhookAt

func (cw *Chatwright) WebhookAt(url string)

WebhookAt points Chatwright at an already-running bot webhook (a bot process started separately, in any language). The emulator POSTs updates to url.

type CheckpointEvidence

type CheckpointEvidence struct {
	ID             CheckpointID
	Name           string
	InvocationPath InvocationPath
	ParentID       CheckpointID
	Lineage        []CheckpointID
	Source         SourceReference
}

CheckpointEvidence records a named checkpoint and its qualified lineage. Lineage contains ancestor checkpoint IDs in root-to-parent order.

type CheckpointID

type CheckpointID string

CheckpointID is a checkpoint's qualified machine identity.

type Definition

type Definition struct {
	Name   string
	Source SourceReference
}

Definition identifies a scenario or reusable fragment and its source.

type EffectiveInputs

type EffectiveInputs[T any] struct {
	Value   T
	Sources map[string]InputSource
}

EffectiveInputs holds a fragment's typed input value and the provenance of its named fields. Sources is copied for each invocation.

type ExecutionContext

type ExecutionContext struct {
	// contains filtered or unexported fields
}

ExecutionContext is the small, invocation-local context used by scenarios and fragments to record source-linked evidence and checkpoint lineage.

func NewExecutionContext

func NewExecutionContext(definition Definition, path ...string) (*ExecutionContext, error)

NewExecutionContext creates a root scenario execution. Each path argument is one machine-path segment, for example "listus" and "new-user".

func (*ExecutionContext) Checkpoint

func (c *ExecutionContext) Checkpoint(name string, source SourceReference) (CheckpointEvidence, error)

Checkpoint records a named checkpoint qualified by the current invocation path. The previously active checkpoint, including one inherited from a parent invocation, becomes its parent and is appended to its lineage.

func (*ExecutionContext) Evidence

func (c *ExecutionContext) Evidence() ExecutionEvidence

Evidence returns a detached snapshot of the evidence produced directly by this execution context.

func (*ExecutionContext) Path

func (c *ExecutionContext) Path() InvocationPath

Path returns the qualified path for this execution context.

func (*ExecutionContext) RecordBranch

func (c *ExecutionContext) RecordBranch(name string, source SourceReference) BranchEvidence

RecordBranch adds source-linked evidence for a locally declared branch.

func (*ExecutionContext) RecordFailure

func (c *ExecutionContext) RecordFailure(err error, source SourceReference) FailureEvidence

RecordFailure adds source-linked evidence for a locally observed failure.

func (*ExecutionContext) RecordStep

func (c *ExecutionContext) RecordStep(name string, source SourceReference) StepEvidence

RecordStep adds source-linked evidence for a locally produced step.

type ExecutionEvidence

type ExecutionEvidence struct {
	Path        InvocationPath
	Definition  Definition
	Steps       []StepEvidence
	Checkpoints []CheckpointEvidence
	Branches    []BranchEvidence
	Failures    []FailureEvidence
}

ExecutionEvidence is a snapshot of evidence produced directly by an execution context. Evidence from nested fragment invocations remains attached to those invocations rather than being flattened into their caller.

type FailureEvidence

type FailureEvidence struct {
	Message        string
	InvocationPath InvocationPath
	Source         SourceReference
}

FailureEvidence records a failure attributed to one invocation and source.

type Fragment

type Fragment[T any] struct {
	Definition  Definition
	CloneInputs func(T) T
	Execute     func(*ExecutionContext, T) error
}

Fragment is a reusable typed scenario definition. CloneInputs must return a detached copy: Chatwright calls it separately for execution and evidence so a fragment cannot mutate its caller's inputs or its recorded effective inputs.

type FragmentInvocation

type FragmentInvocation[T any] struct {
	Path        InvocationPath
	ParentPath  InvocationPath
	Definition  Definition
	Inputs      EffectiveInputs[T]
	Steps       []StepEvidence
	Checkpoints []CheckpointEvidence
	Branches    []BranchEvidence
	Failures    []FailureEvidence
}

FragmentInvocation is the source-linked evidence for one fragment call. Its locally produced records do not include evidence from nested fragments.

func InvokeFragment

func InvokeFragment[T any](
	parent *ExecutionContext,
	invocationName string,
	fragment Fragment[T],
	inputs EffectiveInputs[T],
) (FragmentInvocation[T], error)

InvokeFragment executes a reusable fragment beneath parent. invocationName is part of the machine path, allowing the same definition and checkpoint labels to be used more than once without identity collisions.

type InlineQuery added in v0.6.0

type InlineQuery struct {
	// contains filtered or unexported fields
}

InlineQuery is a lazy expectation for Telegram-style inline-mode results. It is obtained through Chat.SendInlineQuery and resolves on Snapshot or ResultCount. Inline answers are intentionally separate from chat messages: answering a query does not itself post the selected result to a chat.

func (*InlineQuery) ResultCount added in v0.6.0

func (q *InlineQuery) ResultCount(want int) *InlineQuery

ResultCount asserts the number of inline results.

func (*InlineQuery) Select added in v0.6.0

func (q *InlineQuery) Select(index int) *SelectedInlineResult

Select chooses one answered inline result and delivers the platform's chosen-result update back to the bot. The selected result remains chat-independent and is addressed only by its opaque inline-message ID.

func (*InlineQuery) Snapshot added in v0.6.0

func (q *InlineQuery) Snapshot() platform.InlineQueryAnswer

Snapshot returns a detached copy of the normalized inline answer.

type InputSource

type InputSource struct {
	Kind      string
	Reference string
	Source    SourceReference
}

InputSource describes where one named effective input came from. Kind is an application-defined category such as "fixture", "default", or "override".

type InvocationPath

type InvocationPath string

InvocationPath is the qualified machine path of a scenario or fragment invocation. Paths are built from escaped, non-empty segments.

func (InvocationPath) String

func (p InvocationPath) String() string

type Metrics

type Metrics struct {
	Latency time.Duration
}

Metrics are first-class measurements captured for a bot message.

type Option

type Option func(*Chatwright)

Option configures a Chatwright harness at construction time.

func OnPlatform

func OnPlatform(p platform.Platform) Option

OnPlatform selects the platform a scenario runs against, e.g. cw.OnPlatform(whatsapp.Platform()). Defaults to Telegram.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient overrides the HTTP client used to deliver updates to the bot's webhook. Rarely needed; useful for custom timeouts or transports.

func WithListenAddr

func WithListenAddr(addr string) Option

WithListenAddr binds the emulated platform API server to a caller-chosen local address (e.g. "127.0.0.1:54321") instead of a random port.

The common case — ServeWebhook driving an in-process bot — never needs this: cw.BotAPIURL() is available as soon as New returns, before the bot is even constructed. It matters for a bot-under-test started as a separate process, in any language, since Chatwright only speaks HTTP: that process reads its API base URL from its own configuration (e.g. an environment variable) at start-up, so the address must be decided before New runs, not read back from it afterwards. Pick a free address once (e.g. bind to "127.0.0.1:0", read the assigned port, then close it), configure the process with it, and pass the same address here — the emulator then binds exactly where the process already expects it, regardless of which of the two is started first. See examples/pybot for a complete non-Go example using this seam.

Only platforms that implement platform.AddrPlatform support this (Telegram does); New fails the test via t.Fatalf if a non-empty address is set for a platform that doesn't, and if the address itself cannot be bound (e.g. already in use).

func WithSafetyTimeout

func WithSafetyTimeout(d time.Duration) Option

WithSafetyTimeout overrides the wall-clock ceiling Chatwright waits for a bot reply before failing a test (default 5s). It applies to every BotMessage wait regardless of any per-assertion Within budget: Within records a latency budget asserted once a reply arrives, but never shortens how long Chatwright is willing to keep listening. Lower it in fast, tight test suites to fail sooner when a bot never replies at all; raise it if the bot-under-test is intrinsically slow (e.g. it calls a real, unmocked external service).

func WithWebhookHandler

func WithWebhookHandler(h http.Handler) Option

WithWebhookHandler attaches the bot-under-test's webhook handler at construction time, equivalent to calling ServeWebhook after New.

type SelectedInlineResult added in v0.6.0

type SelectedInlineResult struct {
	// contains filtered or unexported fields
}

func (*SelectedInlineResult) InlineMessageID added in v0.6.0

func (s *SelectedInlineResult) InlineMessageID() string

func (*SelectedInlineResult) WaitForEdit added in v0.6.0

WaitForEdit waits for the selected inline message's next in-place edit and returns its normalized current content.

type SourceReference

type SourceReference struct {
	URI      string
	Revision string
}

SourceReference links scenario evidence to the definition that produced it. URI should identify the source location (usually a VCS blob URL), while Revision identifies the exact version that was executed.

type StepEvidence

type StepEvidence struct {
	Name           string
	InvocationPath InvocationPath
	Source         SourceReference
}

StepEvidence records a step produced directly by one invocation.

type User

type User struct {
	ID           string
	FirstName    string
	LastName     string
	Username     string
	LanguageCode string
}

User identifies a participant in a conversation. ID is a stable handle (e.g. "alice"); Chatwright maps it to a deterministic per-platform numeric ID.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL