Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A dashboard for your Playwright tests - across projects and over time - with an embedded trace viewer.

**[Live demo](https://demo.kinora.dev)** (read-only, no sign-up) · [Website](https://kinora.dev) · [Cloud](https://app.kinora.dev)

Playwright ships a great HTML report for a single run. kinora sits one level up: push every CI run to a kinora server and get one place to track pass rates, spot trends, and surface flaky tests over time. Failing tests get a **View trace** button that opens the full Playwright trace (DOM / timeline / network / console) right in the dashboard, no separate tooling.

<picture>
Expand Down
12 changes: 11 additions & 1 deletion landing/src/components/CtaSection.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { ArrowRight } from '@lucide/astro'
import { ArrowRight, Play } from '@lucide/astro'
import { SITE } from '../lib/site'
import GithubIcon from './GithubIcon.astro';
---
Expand Down Expand Up @@ -51,6 +51,16 @@ import GithubIcon from './GithubIcon.astro';
Get started
<ArrowRight class="size-4" aria-hidden="true" />
</a>
<a
href={SITE.demo}
target="_blank"
rel="noopener noreferrer"
data-umami-event="live-demo-cta"
class="inline-flex h-11 items-center gap-2 rounded-md border border-border px-6 font-mono text-sm font-semibold transition-colors hover:bg-muted"
>
<Play class="size-4" aria-hidden="true" />
Live demo
</a>
<a
href={SITE.repo}
target="_blank"
Expand Down
12 changes: 11 additions & 1 deletion landing/src/components/HeroSection.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { ArrowRight } from '@lucide/astro'
import { ArrowRight, Play } from '@lucide/astro'
import overviewDark from '../assets/screenshots/overview-dark.png'
import overviewLight from '../assets/screenshots/overview-light.png'
import { SITE } from '../lib/site'
Expand Down Expand Up @@ -68,6 +68,16 @@ import Screenshot from './Screenshot.astro';
Get started
<ArrowRight class="size-4" aria-hidden="true" />
</a>
<a
href={SITE.demo}
target="_blank"
rel="noopener noreferrer"
data-umami-event="live-demo-hero"
class="inline-flex h-11 items-center gap-2 rounded-md border border-border px-6 font-mono text-sm font-semibold transition-colors hover:bg-muted bg-background"
>
<Play class="size-4" aria-hidden="true" />
Live demo
</a>
<a
href={SITE.repo}
target="_blank"
Expand Down
9 changes: 9 additions & 0 deletions landing/src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ import Wordmark from './Wordmark.astro';
>
<GithubIcon class="size-4" />
</a>
<a
href={SITE.demo}
target="_blank"
rel="noopener noreferrer"
data-umami-event="live-demo-header"
class="hidden h-9 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:inline-flex"
>
Demo
</a>
<a
href={SITE.login}
data-umami-event="signin-header"
Expand Down
1 change: 1 addition & 0 deletions landing/src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const SITE = {
app: 'https://app.kinora.dev',
login: 'https://app.kinora.dev/login',
signup: 'https://app.kinora.dev/signup',
demo: 'https://demo.kinora.dev',
repo: 'https://github.com/Kinora-dev/kinora',
selfhost: 'https://github.com/Kinora-dev/kinora/tree/main/selfhost',
download: 'https://github.com/Kinora-dev/kinora/releases/latest',
Expand Down
9 changes: 9 additions & 0 deletions packages/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 kinora
COPY --from=builder --chown=kinora:nodejs /prod /app
# Demo seed (seed-market) uploads real trace.zip blobs; it resolves them at
# ../../trace-viewer/public/fixtures relative to dist/scripts -> /app/trace-viewer/public/fixtures.
COPY --chown=kinora:nodejs \
packages/trace-viewer/public/fixtures/error-trace.zip \
packages/trace-viewer/public/fixtures/demo.zip \
/app/trace-viewer/public/fixtures/
# Pre-create STORAGE_DIR owned by kinora so a fresh named volume inherits uid 1001 (else
# Docker inits the volume root-owned and the non-root process can't write artifacts).
RUN mkdir -p /app/.data/artifacts && chown -R kinora:nodejs /app/.data
USER kinora
EXPOSE 3000

Expand Down
46 changes: 46 additions & 0 deletions packages/server/scripts/reset-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { execSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { sql } from 'drizzle-orm'
import { db } from '../src/db'
import { demo, env, s3 } from '../src/lib/env'
import { logger } from '../src/lib/logger'

// Daily demo refresh (Dokploy schedule): wipe + reseed so the dataset is fresh and run dates
// re-anchor to today. Truncates rather than drops so the live server keeps its connection.
async function main(): Promise<void> {
if (!demo) {
logger.error('reset-demo refuses to run without KINORA_DEMO=true (never wipe a real deployment)')
process.exit(1)
}
if (s3)
logger.warn('reset-demo only clears local STORAGE_DIR; S3 blobs would orphan. Run the demo on FS storage.')

// Empty every data table (keep schema + knex migration bookkeeping), reset identities.
const result = await db.execute(sql`
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT LIKE 'knex_migrations%'
`)
const tables = (result.rows as { tablename: string }[]).map(r => `"${r.tablename}"`)
if (tables.length)
await db.execute(sql.raw(`TRUNCATE ${tables.join(', ')} RESTART IDENTITY CASCADE`))

// Drop the now-orphaned trace blobs. Clear the dir's CONTENTS, not the dir itself: STORAGE_DIR
// is a mounted volume whose mountpoint is root-owned -> removing it would EACCES.
const artifactsDir = path.resolve(env.STORAGE_DIR)
if (existsSync(artifactsDir)) {
for (const entry of readdirSync(artifactsDir))
rmSync(path.join(artifactsDir, entry), { recursive: true, force: true })
}

// Reseed the marketing dataset (run dates are relative to now -> fresh every run).
execSync('node dist/scripts/seed-market.mjs --force', { stdio: 'inherit', env: process.env })
}

main()
.then(() => process.exit(0))
.catch((err) => {
logger.error(err)
process.exit(1)
})
6 changes: 4 additions & 2 deletions packages/server/scripts/seed-market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { countsByTagFrom, makeTestKey } from '@kinora/core'
import { eq } from 'drizzle-orm'
import { db } from '../src/db'
import { apikey, artifact, member, project, run, test, user as userTable } from '../src/db/schemas/index'
import { DEMO_PASSWORD } from '../src/demo/owner'
import { auth } from '../src/lib/auth'
import { env } from '../src/lib/env'
import { logger } from '../src/lib/logger'
Expand All @@ -16,7 +17,8 @@ import { storage } from '../src/lib/storage'
// Curated, DETERMINISTIC marketing data: same output every reseed so screenshots
// stay reproducible. Separate account from the dev demo seed (scripts/seed.ts).
const EMAIL = 'market@kinora.dev'
const PASSWORD = 'password123'
// The demo auto-session signs in with this; shared so the two never drift apart.
const PASSWORD = DEMO_PASSWORD
const NAME = 'Kinora'

const RUNS = 30
Expand Down Expand Up @@ -269,7 +271,7 @@ async function main(): Promise<void> {
const passTrace = await readFile(PASS_TRACE)

await db.delete(project).where(eq(project.organizationId, orgId))
const apiKey = await auth.api.createApiKey({ body: { name: 'market seed token', userId } })
const apiKey = await auth.api.createApiKey({ body: { name: 'ci-github-actions', userId } })
await db.update(apikey).set({ referenceId: orgId }).where(eq(apikey.id, apiKey.id))

for (const pdef of PROJECTS) {
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { bodyLimit } from 'hono/body-limit'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import { db } from './db'
import { demoApp } from './demo/session'
import { accessLog } from './lib/access-log'
import { verifyArtifactSignature } from './lib/artifact-url'
import { auth } from './lib/auth'
import { blockAuthWritesInDemo, blockInDemo } from './lib/demo-guard'
import { env } from './lib/env'
import { logger } from './lib/logger'
import { clientIp, rateLimit } from './lib/rate-limit'
Expand Down Expand Up @@ -50,13 +52,16 @@ app.get('/healthcheck', async (c) => {
}
})

app.use('/api/auth/*', blockAuthWritesInDemo)
app.on(['POST', 'GET'], '/api/auth/*', c => auth.handler(c.req.raw))

app.use('/trpc/*', rateLimit({ windowMs: 60_000, limit: 300 }))
app.use('/trpc/*', trpcServer({ router: appRouter, createContext }))

// Access log first in the chain so rate-limit (429) / body-limit (413) rejections are logged too.
app.use('/api/v1/*', accessLog)
// Read-only demo: reject all ingest writes (logged by accessLog above).
app.use('/api/v1/*', blockInDemo)
// Per-IP, before the body is read, so a flood is cheap to reject. Keyed by IP (not token) so
// sharded CI spreads across runner IPs instead of summing into one bucket. Real throttle is
// nginx limit_req (unspoofable IP); this is the backstop.
Expand All @@ -69,8 +74,13 @@ app.use('/api/v1/*', bodyLimit({
app.route('/api/v1', publicApi)

app.use('/api/slack/*', accessLog)
// Read-only demo: the Slack OAuth callback writes the integration (bypassing tRPC), so block it here.
app.use('/api/slack/*', blockInDemo)
app.route('/api/slack', slackOAuth)

// Demo-only: establishes the shared read-only session cookie (no-op otherwise).
app.route('/api/demo', demoApp)

app.onError((err, c) => {
logger.error({ err }, 'unhandled request error')
Sentry.captureException(err)
Expand Down
19 changes: 19 additions & 0 deletions packages/server/src/demo/owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { asc, eq } from 'drizzle-orm'
import { db } from '../db'
import { member, user as userTable } from '../db/schemas/index'

// Seed-market sets this on the demo account; the auto-session signs in with it. Keep in sync there.
export const DEMO_PASSWORD = 'password123'

// The seeded primary account = earliest org owner. Looked up per-request (not cached) so the
// daily reseed's new ids are picked up without a restart.
export async function resolveDemoOwner() {
const [owner] = await db
.select({ user: userTable, organizationId: member.organizationId })
.from(member)
.innerJoin(userTable, eq(member.userId, userTable.id))
.where(eq(member.role, 'owner'))
.orderBy(asc(userTable.createdAt))
.limit(1)
return owner ?? null
}
22 changes: 22 additions & 0 deletions packages/server/src/demo/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Hono } from 'hono'
import { auth } from '../lib/auth'
import { demo } from '../lib/env'
import { DEMO_PASSWORD, resolveDemoOwner } from './owner'

export const demoApp = new Hono()

// Sign the seeded demo account in here so the browser gets a real (read-only) session cookie:
// better-auth client calls (org, members, role) need it; the tRPC fallback alone wouldn't cover them.
demoApp.get('/session', async (c) => {
if (!demo)
return c.body(null, 204)

const owner = await resolveDemoOwner()
if (!owner)
return c.body(null, 204)

const res = await auth.api.signInEmail({ body: { email: owner.user.email, password: DEMO_PASSWORD }, asResponse: true })
for (const cookie of res.headers.getSetCookie())
c.header('set-cookie', cookie, { append: true })
return c.body(null, 204)
})
4 changes: 2 additions & 2 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { serve } from '@hono/node-server'
import process from 'node:process'
import { app } from './app'
import { db } from './db'
import { env } from './lib/env'
import { demo, env } from './lib/env'
import { logger } from './lib/logger'

// Log stray rejections instead of letting one crash the whole server; uncaught exceptions leave the
Expand All @@ -16,7 +16,7 @@ process.on('uncaughtException', (err) => {
})

const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
logger.info(`kinora server running on port ${info.port}`)
logger.info(`${demo ? '[DEMO] ' : ''}kinora server running on port ${info.port}`)
})

let shuttingDown = false
Expand Down
10 changes: 6 additions & 4 deletions packages/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { polarAuthPlugin, polarClient } from '../billing/polar'
import { db } from '../db'
import { member, organization as organizationTable } from '../db/schemas/index'
import { purgeUserOwnedData } from './account'
import { env } from './env'
import { demo, env } from './env'
import { logger } from './logger'
import { mailerEnabled, sendMail } from './mailer'
import { getTrustedOrigins } from './utils'
Expand Down Expand Up @@ -124,9 +124,11 @@ export const auth = betterAuth({
},
},
},
advanced: env.COOKIE_DOMAIN
? { crossSubDomainCookies: { enabled: true, domain: env.COOKIE_DOMAIN } }
: {},
advanced: {
...(env.COOKIE_DOMAIN ? { crossSubDomainCookies: { enabled: true, domain: env.COOKIE_DOMAIN } } : {}),
// Demo runs on a *.kinora.dev subdomain next to prod; a distinct cookie name stops prod's
...(demo ? { cookiePrefix: 'kinora-demo' } : {}),
},
secret: env.AUTH_SECRET,
plugins: [
// Plugin default is 10 req/day per key, which any real CI exceeds, billing quotas already cap ingest volume.
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/lib/demo-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createMiddleware } from 'hono/factory'
import { demo } from './env'

// Read-only demo: block the public ingest API entirely (the dashboard uses tRPC, gated separately).
export const blockInDemo = createMiddleware(async (c, next) => {
if (demo)
return c.json({ error: 'This is a read-only demo' }, 403)
return next()
})

// Block auth writes (sign-up/password/delete) but keep reads; the demo auto-sessions so no login is needed.
export const blockAuthWritesInDemo = createMiddleware(async (c, next) => {
if (demo && c.req.method === 'POST')
return c.json({ error: 'This is a read-only demo' }, 403)
return next()
})
4 changes: 4 additions & 0 deletions packages/server/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const envSchema = z.object({
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
KINORA_CLOUD: z.stringbool().default(false),
// Public demo instance: auto-session as the seeded demo user + read-only (no mutations/ingest/auth writes).
KINORA_DEMO: z.stringbool().default(false),
POLAR_ACCESS_TOKEN: z.string().optional(),
POLAR_WEBHOOK_SECRET: z.string().optional(),
POLAR_PRODUCT_TEAM_ID: z.string().optional(),
Expand Down Expand Up @@ -79,6 +81,8 @@ function resolveCloud(): CloudConfig | null {

export const cloud = resolveCloud()

export const demo = env.KINORA_DEMO

export interface S3Config {
endpoint: string
region: string
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/router/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { slackApp } from '../lib/env'
import { demo, slackApp } from '../lib/env'
import { mailerEnabled } from '../lib/mailer'
import { publicProcedure, router } from '../trpc/index'

Expand All @@ -9,5 +9,7 @@ export const configRouter = router({
mailerEnabled,
// Slack OAuth app present? front shows "Add to Slack" vs manual webhook paste.
slackOauthEnabled: slackApp !== null,
// Public read-only demo? front shows a banner + hides mutation UI.
demo,
})),
})
10 changes: 10 additions & 0 deletions packages/server/src/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import type { AuthType } from '../lib/auth'
import { and, eq } from 'drizzle-orm'
import { db } from '../db'
import { member } from '../db/schemas/index'
import { resolveDemoOwner } from '../demo/owner'
import { auth } from '../lib/auth'
import { demo } from '../lib/env'

export async function createContext({ req }: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: req.headers })
Expand All @@ -18,6 +21,13 @@ export async function createContext({ req }: FetchCreateContextFnOptions) {
organizationId = owned?.organizationId ?? null
}

if (!user && demo) {
const d = await resolveDemoOwner()
// db row is structurally the session user; cast to the auth user type for ctx consistency.
if (d)
return { user: d.user as unknown as AuthType['user'], organizationId: d.organizationId, req }
}

return { user, organizationId, req }
}

Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/trpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { initTRPC, TRPCError } from '@trpc/server'
import { and, eq } from 'drizzle-orm'
import { db } from '../db'
import { member } from '../db/schemas/index'
import { demo } from '../lib/env'
import { logger } from '../lib/logger'

export const t = initTRPC.context<Context>().create()

const loggedProcedure = t.procedure.use(async (opts) => {
// Public demo is browse-only: reject every mutation (one gate for all routers).
if (demo && opts.type === 'mutation')
throw new TRPCError({ code: 'FORBIDDEN', message: 'This is a read-only demo' })
const start = Date.now()
const result = await opts.next()
const ms = Date.now() - start
Expand Down
Loading