Examples
SaaS with integrations
Relay, a team-notes app: workspaces on Clerk organizations, a Stripe trial and billing, Slack and Google Calendar connected by OAuth, live notes over Pusher, and Inngest jobs that send email through Resend.
Relay, a team-notes app: workspaces on Clerk organizations, a Stripe trial and billing, Slack and Google Calendar connected by OAuth, live notes over Pusher, and Inngest jobs that send email through Resend. See it on GitHub.
What it shows
- Onboarding that makes one workspace, one Clerk organization, one Stripe customer and one trialing subscription, however Clerk and Stripe fail on the way (102 trials)
- Time travel through a trial: Stripe's trial_will_end on day 11 and the first invoice on day 14 start Inngest jobs, and the app's status matches Stripe's
- A note posted to Slack exactly once under Slack 429s, 5xx and lost answers, and seen live by a teammate in a second browser
SAAS_BUGswitches that turn one safeguard off, so you can watch the matrix catch it
Stack
TanStack StartHonoInngestPostgresDrizzleTailwind CSSEmulates: Clerk, Stripe, Slack, Google, Pusher, Resend. Runs on: A TanStack Start app, a Hono API, an Inngest worker and the Inngest dev server, with Postgres in Docker.
Run it
Copy examples/saas anywhere, then from its folder:
npm install
npx @danwo/cli testIts scripts:
npm run build # vite build web
npm run typecheck # tsc --noEmit && tsc --noEmit -p web
npm run test # danwo test
npm run test:agentic # danwo test tests/agentic.danwo.ts
npm run matrix # danwo matrix tests/notes.danwo.ts --grep "posts a published note"
npm run matrix:plan # danwo matrix tests/notes.danwo.ts --grep "posts a published note" --plan
npm run matrix:onboarding # danwo matrix tests/onboarding.danwo.ts --grep "signs up and starts a trial"
npm run matrix:billing # danwo matrix tests/billing.danwo.ts --grep "the trial ends"
npm run serve # danwo serveIts README says what else it needs and what the last full matrix found.
Until the danwo version it pins is on npm, install it from a local build instead: from the repository's root, bun run release <version> --pack, then node examples/link.ts saas, which installs a copy and prints where it is.
Background jobs explains what this example leans on.
The test
/**
* Notes: publishing one posts it to the workspace's Slack channel once (a background job on
* Inngest), and a teammate with the workspace open sees it arrive live (Pusher).
*/
import { resendInbox, slackWorkspace, test as base } from "@danwo/sdk"
import {
ADA,
BOB,
clerkMembers,
clerkOrganizations,
expectPostedOnce,
expectWelcomedOnce,
openSection,
type RelayFixtures,
signUp,
WORKSPACE,
writeNote,
} from "./relay.ts"
const test = base.extend<RelayFixtures>()
test(
"posts a published note to Slack once",
{ session: "owner-with-slack" },
async ({ app, screen, expect, mail, slack, settle }) => {
await app.open("/")
await writeNote(
{ app, screen, expect, mail },
{ title: "Weekly update", body: "Billing shipped. Next: the calendar sync." },
)
await settle()
const [message] = slackWorkspace(slack).messages("#product")
expect(message?.text).toContain("*Weekly update* by Ada Lovelace")
},
).invariant(async (fixtures) => {
await expectPostedOnce(fixtures)
fixtures.expect(await fixtures.sql("select id from notes"), "one note saved").toHaveLength(1)
})
test(
"keeps a draft out of Slack until it is published",
{ session: "owner-with-slack" },
async ({ app, screen, expect, mail, slack, settle }) => {
await app.open("/")
await writeNote(
{ app, screen, expect, mail },
{ title: "Pricing ideas", body: "Rough.", draft: true },
)
await settle()
expect(slackWorkspace(slack).messages("#product")).toHaveLength(0)
await screen
.getByRole("article", { name: "Pricing ideas" })
.getByRole("button", { name: "Publish" })
.click()
await expect(
screen.getByRole("article", { name: "Pricing ideas" }).getByText("Published"),
).toBeVisible()
await settle()
expect(slackWorkspace(slack).messages("#product")).toHaveLength(1)
},
).invariant(expectPostedOnce)
test(
"a teammate sees a published note live",
{ session: "owner-in-workspace" },
async ({ app, screen, expect, mail, users }) => {
await app.open("/")
await openSection({ app, screen, expect, mail }, "Settings")
await screen.getByRole("link", { name: "Members" }).click()
await screen.getByLabel("Email address").fill(BOB.email)
await screen.getByRole("button", { name: "Send invite" }).click()
await expect(screen.getByRole("status")).toHaveText(`Invitation sent to ${BOB.email}.`)
/* Bob signs up in his own browser and accepts; the front door finds his invitation. */
const bob = await users.open("bob")
await signUp(bob, BOB)
await bob.expect(bob.screen.getByRole("heading", { name: `Join ${WORKSPACE}` })).toBeVisible()
await bob.screen.getByRole("button", { name: "Accept the invitation" }).click()
await bob.expect(bob.screen.getByRole("heading", { level: 1, name: "Notes" })).toBeVisible()
await openSection({ app, screen, expect, mail }, "Notes")
await writeNote(
{ app, screen, expect, mail },
{ title: "Q4 roadmap", body: "1. Calendar sync\n2. Mobile app" },
)
/* No reload: the note reaches Bob's open page over Pusher. */
await bob.expect(bob.screen.getByRole("article", { name: "Q4 roadmap" })).toBeVisible()
await bob
.expect(bob.screen.getByRole("status"))
.toHaveText(`${ADA.name} published “Q4 roadmap”`)
},
).invariant(async (fixtures) => {
const { sql, clerk, resend, expect } = fixtures
const [org] = await clerkOrganizations(clerk)
const members = await sql<{ email: string; role: string }>(
"select u.email, m.role from members m join users u on u.id = m.user_id order by u.email",
)
expect(members, "Ada and Bob are members").toEqual([
{ email: ADA.email, role: "org:admin" },
{ email: BOB.email, role: "org:member" },
])
expect(
clerkMembers(clerk, org?.id ?? "").toSorted((a, b) =>
(a.email ?? "").localeCompare(b.email ?? ""),
),
"and so are they in the Clerk organization",
).toEqual(members)
expect(
resendInbox(resend)
.list(BOB.email)
.filter((email) => email.subject.includes(`invited you to ${WORKSPACE}`)),
"one invitation email",
).toHaveLength(1)
await expectWelcomedOnce(fixtures)
})Background jobs
A team digest sent by background jobs on Inngest: a React page, a Hono API on Postgres, and a worker whose cron, fan-out, retries and onFailure send through Resend and bill through Stripe.
Next.js waitlist
A waitlist on Next.js: the form saves an email to Postgres and sends a welcome email through Resend.