Sub-skills

Testing in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill testing`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/testing.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

What to test and how: pure logic with cubek test (bun), routes and data with cubek dev + curl, and cubek check after every edit.

Unit tests (cubek test)

*.test.ts files beside the code run under bun and are never deployed. validate, crypto, encoding, csv, fmt, markdown, time work in tests; I/O primitives (db, kv, storage, fetch, task) throw (push.send records: push.md). So keep decisions in pure functions and let routes do the I/O.

// src/lib/pricing.ts
import { validate, type Infer } from "cubek"

export const cartInput = validate.object({
  items: validate.array(validate.object({ sku: validate.string({ min: 1 }), qty: validate.number({ int: true, min: 1, max: 99 }), cents: validate.number({ int: true, min: 0 }) }), { min: 1 }),
  coupon: validate.string().optional(),
})
export type Cart = Infer<typeof cartInput>

export function total(cart: Cart): { subtotal: number; discount: number; total: number } {
  let subtotal = 0
  for (const it of cart.items) subtotal += it.qty * it.cents
  const discount = cart.coupon === "TEN" ? Math.round(subtotal / 10) : 0
  return { subtotal, discount, total: subtotal - discount }
}

export function slugify(s: string): string {
  return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
}
// src/lib/pricing.test.ts
import { describe, expect, test } from "bun:test"
import { cartInput, slugify, total } from "./pricing"

describe("total", () => {
  test("applies the coupon", () => {
    const cart = cartInput.parse({ items: [{ sku: "a", qty: 2, cents: 500 }], coupon: "TEN" })
    expect(total(cart)).toEqual({ subtotal: 1000, discount: 100, total: 900 })
  })
  test("rejects an empty cart", () => {
    expect(cartInput.safeParse({ items: [] }).success).toBe(false)
  })
})

test("slugify", () => {
  expect(slugify("  Hello, World! ")).toBe("hello-world")
})
cubek test                    # every *.test.ts
cubek test -- pricing         # bun args after --: a file filter

Time-based logic: setSystemTime(new Date("2026-09-27T05:59:59Z")) from "bun:test" fixes Date and time.* in a test (setSystemTime() restores it). cubek dev --now 2026-09-27T05:59:59Z starts the dev server's clock there (it keeps running: Date, time.*, db "now", kv TTLs, cron).

To sanity-run lib code with the real compiler (in-memory db, kv...), give a file an export function main() and run cubek run src/lib/try.ts: it prints main()'s result as JSON; imports resolve as in cubek check.

Routes, data and tasks (cubek dev + curl)

The dev server is the production runtime with a local SQLite: exercise the real routes.

cubek dev --json &                            # prints {"ok":true,"url":...} once it listens
curl -s localhost:8787/api/items
curl -s -X POST localhost:8787/api/items -H 'content-type: application/json' -d '{"name":"pen","qty":3}'
curl -s -c jar -b jar -X POST localhost:8787/auth/signin -d 'email=a@b.co&password=secret123'   # a form, keeps the cookie
curl -s -b jar localhost:8787/admin/users
cubek sql --dev "SELECT COUNT(*) AS n FROM items"                                                # the effect in the database
cubek task run cleanup                        # a task or cron now
cubek dev --stop                              # this project's server only
  • Check status codes too: curl -s -o /dev/null -w '%{http_code}\n' ....
  • The dev output shows each request, logs, errors and slow queries with the missing index. GET /_cubek/stats: per-route latency.
  • Dev secrets in .cubek/secrets.json (cubek secrets --dev set NAME VALUE).
  • Reset the dev data: stop the server, delete .cubek/dev.sqlite* (or seed with cubek sql --dev --write using fixed ids and INSERT OR IGNORE).

Live queries and realtime (curl)

A live query is an SSE stream: /_cubek/live?q=<file>%23<export>.<args>.0, <args> the base64url of the arguments' JSON (keys sorted, no spaces; none: null = bnVsbA). The first event: q has the result; a write sends the next; event: e is a thrown error. Without -b jar it runs signed out.

A=$(printf '{"q":"pen"}' | base64 | tr '+/' '-_' | tr -d '=')
curl -sN -b jar "localhost:8787/_cubek/live?q=src/queries.ts%23itemsQuery.$A.0" &   # data: {"k":...,"v":[...]}
curl -s -b jar -X POST localhost:8787/api/items -d 'name=pen&qty=1'                 # the stream prints the new result
curl -sN "localhost:8787/_cubek/realtime?channel=room:1&token=$T"                    # $T from realtime.token; none: 403

In a browser (end to end)

Pages are server-rendered HTML: curl covers most routes. For islands, mount points and live updates, drive a real browser against cubek dev (or a preview URL) with Playwright, a dev dependency outside src/ (never compiled or deployed):

bun add -d playwright && bunx playwright install chromium
// e2e/smoke.ts: `bun e2e/smoke.ts` while `cubek dev` runs
import { chromium } from "playwright"
const base = process.env.BASE ?? "http://127.0.0.1:8787"
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`${base}/`)
await page.fill("input[name=title]", "from e2e")
await page.click("button[type=submit]")
await page.getByText("from e2e").waitFor()   // rendered, or pushed by a live query
await browser.close()

Name it *.ts, not *.test.ts (cubek test runs those under bun:test). On a private preview, page.goto the link of cubek preview url <N> first (it leaves the access cookie in that browser).

After a deploy

cubek deploy                   # a preview: https://<name>--r<N>.cubek.app with a copy of production's data
curl -s -H "Authorization: Bearer $(cubek tokens print)" https://<name>--r<N>.cubek.app/healthz   # previews are private
cubek logs --level warn        # 500s carry their error here
cubek promote <N>              # that preview becomes production, unchanged

A GET /healthz route answering ok makes smoke checks one line.