Guidance for agents in this repo. Shared stack conventions are imported above; everything below is kinora-specific and overrides the profile where they differ.
kinora is a dashboard for Playwright test reports across projects and over time, with an embedded trace viewer. CI runs push results to a self-hosted kinora server; the dashboard tracks pass rates, trends, and flaky tests, and opens the full Playwright trace inline for failures.
pnpm workspace monorepo. Node 26, ESM-only (except desktop, see below), TypeScript strict. Fair source: server, web, and desktop are FSL-1.1-MIT (source-available, converts to MIT after 2 years); the embeddable libs (reporter, cli, core, ui, mcp) and trace-viewer are MIT.
packages/desktop is an Electron app: a local Playwright trace viewer (no account) plus an account dashboard that signs in to a kinora server. See the Desktop app section below.
Run from the repo root unless noted.
pnpm install
pnpm build # pnpm -r build, every package (tsdown for libs/server, vite for web/viewer)
pnpm typecheck # tsc / vue-tsc across the workspace
pnpm lint # eslint . (lint:fix to autofix)
pnpm test # pnpm -r test (vitest). NOTE: server has no `test` script, so this skips it
pnpm test:integration # pnpm -r test:integration; only @kinora/server has it (needs Postgres on :5436)
pnpm test:e2e # pnpm -r test:e2e (trace viewer + web); --filter @kinora/web test:e2e to scope
# web e2e self-boots server+web via Playwright `webServer` (packages/web/playwright.config.ts)
# on dedicated ports against the `kinora_e2e` DB; only needs Postgres up on :5436
pnpm dev:server # @kinora/server on :3000 (tsx watch)
pnpm dev:web # dashboard on :5173
pnpm dev:viewer # trace viewer on :5174Desktop (from packages/desktop; build the viewer once first - the app serves its dist/):
pnpm --filter @kinora/trace-viewer build # one-time, from repo root
pnpm dev # build main (cjs) + home UI, launch Electron
pnpm start path/to/trace.zip # open a specific local trace
pnpm probe # headless self-check, exits 0/1 (VIEWER; HOME via probe:home)
pnpm dist:mac # local package (dmg + zip). The signed+notarized release is the Desktop Release CI workflowCI (.github/workflows/ci.yml) is three jobs: check (lint -> typecheck -> build -> test), integration_tests (Postgres service + @kinora/server test:integration), and e2e_tests (Postgres service; pnpm test:e2e self-boots the stack on dedicated ports and resets kinora_e2e via db:reset:e2e, runs both the viewer and web suites). Build is in CI because cross-package types resolve through each lib's build output for published packages.
Single test / package-scoped:
pnpm --filter @kinora/core test # one package's vitest suite
pnpm --filter @kinora/core exec vitest run -t "name" # one test by name
pnpm --filter @kinora/server test:integration # server suite (Postgres must be up)
pnpm --filter @kinora/server typecheck # one package's typecheckServer database (from packages/server, needs .env copied from .env.example and docker compose up -d against packages/server/docker-compose.yml for Postgres on :5436):
pnpm migrate # apply pending migrations (alias: `pnpm migrate latest`); knex CLI args pass through
pnpm db:create # create the database
pnpm db:seed # seed demo account + data, prints login (demo@kinora.dev / password123) + an API token
pnpm db:seed:market # larger "marketing" seed dataset
pnpm db:reset:e2e # drop + recreate `kinora_e2e` (used by web e2e)
pnpm purge-expired-runs # retention sweep: delete runs past their retention windowMigrations are knex, not drizzle: hand-written .ts files in packages/server/migrations/ (timestamp-prefixed), run by scripts/migrate.ts. Drizzle is the query/ORM layer only - there is no drizzle.config.ts and no schema-push/generate flow. To change the schema: edit src/db/schemas/, then add a matching knex migration. The migrate config reads connection details from src/lib/env.ts; in dev it loads .ts migrations, in prod the build emits them as .mjs in dist/.
A test result travels: Playwright run -> reporter or CLI -> @kinora/core normalize -> POST /api/v1/runs -> Postgres -> tRPC dashboard router -> @kinora/web, with trace.zip artifacts on a parallel path.
@kinora/coreis the shared contract layer. zod schemas insrc/contracts/(kinora.ts= stored/dashboard shapes,ingest.ts= the wire payload,playwright.ts= raw report shape) plus pure helpers insrc/lib/(normalize,aggregate,history,compare,status,test-key). Both ingest paths and the server depend on it, so a test keeps a stable identity regardless of how it was uploaded.makeTestKey(file, titlePath, projectName)(core/src/lib/test-key.ts) is the cross-run identity. The reporter rebuilds it from the Playwright suite tree (identity()inreporter/src/index.ts); the CLI derives it vianormalizefromresults.json. Both must produce the same key or history breaks.SCHEMA_VERSION(core/src/contracts/kinora.ts) is stamped onManifestandRunReport. Bump it when the stored/dashboard shape changes.
packages/server is a Hono app (src/app.ts) exposing two surfaces, both via better-auth:
- Public ingest API (
src/public-api/, mounted at/api/v1): plain REST, authed by API key (Bearertoken verified withauth.api.verifyApiKey). This is what the reporter/CLI hit. Kept REST so any CI/curl/language can post. The token'sreferenceIdis the owning user id.read.tsadds API-key GET read routes (projects, runs, failures, per-test history) on the same surface, backed by the sharedsrc/reports/queries.tsservice that also powers the tRPCdashboardrouter;@kinora/mcpconsumes them viacore'screateReadClient. - Dashboard API (
src/router/, tRPC at/trpc): session-cookie authed.authProcedure(insrc/trpc/index.ts) gates onctx.user;dashboardRouterscopes every query to the session user and rejects projects they don't own (ownedProject).
auth.handler serves better-auth's own routes at /api/auth/*; /api/slack is the Slack "Add to Slack" OAuth callback (src/slack/oauth.ts). Order in app.ts matters: /artifacts/* static serving with permissive CORS is registered before secureHeaders/global CORS so its headers don't block the viewer's cross-origin service-worker fetch. /api/v1/* has a bodyLimit (large trace.zip uploads).
The desktop app authenticates via the OAuth 2.0 device authorization grant (better-auth deviceAuthorization plugin in src/lib/auth.ts; client id kinora-desktop). The user approves at WEB_ORIGIN/device (a meta.public web route), and the app polls /api/auth/device/token for the bearer token. So treat device-grant as a third auth path on top of the two REST/tRPC surfaces.
One codebase, two deployment modes gated by KINORA_CLOUD (env). Self-host (false) unlocks every feature; cloud (true) enables Polar billing.
- Billing (
src/billing/):polar.ts(Polar SDK + better-auth plugin),entitlements.ts/usage.ts(plan limits),retention.ts(per-plan run-retention windows, swept bypurge-expired-runs). - Alerts (
src/alerts/): per-project notifications on new failures / regressions. Channels areslack.ts,email.ts(nodemailer/SMTP),webhook.ts, dispatched bynotify.tswith an every-run / on-failure / on-regression policy (core.ts). - Feedback (
src/feedback/,feedbacktRPC router): in-app "Send feedback" posts bug/feature reports to the private cloud task tracker. Cloud-only:resolveFeedbackTrackerinenv.tsreturns null unlessKINORA_CLOUD=trueand allFEEDBACK_TRACKER_*vars are set;config.get.feedbackEnabledgates the web UI. - Email (password reset, verification, invitations, alerts) needs
SMTP_*; social login needsGOOGLE_*/GITHUB_*. All optional - empty disables the flow.
Drizzle (query layer) + Postgres. Schema in src/db/schemas/ split into auth-schemas.ts (better-auth tables) and kinora-schemas.ts (project, run, test, artifact). jsonb columns (counts, git, ci, titlePath, errors, attachments, ...) are typed from @kinora/core types via .$type<>(), so the DB row shape and the contract stay in sync. Schema changes are migrated via knex (see Commands).
Binary artifacts (trace.zip) go through the Storage interface in src/lib/storage.ts: local FS (STORAGE_DIR) by default, or any S3-compatible store (AWS / R2 / MinIO / Hetzner) when all five S3_* env vars are set. The dashboard run query resolves storageKey -> absolute URL at read time and merges it into each test's attachments.
packages/web is Vue 3 + vue-router + tRPC client + Tailwind v4. The tRPC client (src/lib/trpc.ts) imports AppRouter as a type from @kinora/server for end-to-end type safety; requests send credentials for the session cookie. Routing (src/router/index.ts) gates on session.ensure() with a meta.public flag for login/signup. Build-time config comes from VITE_KINORA_* env, validated by @julr/vite-plugin-validate-env in vite.config.ts (VITE_KINORA_SERVER_URL is required). @ aliases src/.
@kinora/ui is the shared shadcn-vue design system (Reka UI + Tailwind), consumed by both web and trace-viewer. Its exports map exposes component groups via ./*.
packages/trace-viewer is the Playwright trace replay engine vendored from microsoft/playwright (Apache-2.0) under src/core/ and src/sw/, wrapped by kinora's own Vue UI in src/ui/
- Do not edit or lint the vendored code.
src/core/**,src/sw/**,src/sw-main.ts, andpublic/sw.bundle.jsare in eslint'signores(eslint.config.js). - The service worker is built as a separate step:
build:sw(vite.sw.config.ts) bundlessrc/sw-main.tsinto a single IIFE classic script atpublic/sw.bundle.js.devandbuildrunbuild:swfirst. - In prod the viewer is served under
/trace/(vite.config.tssetsbase: '/trace/'on build); the dashboard links to it viatraceViewerHref(web/src/lib/trace.ts), passing the artifact URL as?trace=.
packages/desktop is the Electron shell. Its main process runs as CommonJS: Electron's main + sandboxed preload run most reliably as CJS (ESM would force sandbox: false), so tsdown emits dist/main.cjs - the .cjs extension forces CommonJS regardless of the package's "type": "module". A separate Vite build compiles the home UI (home/) to home/dist.
- Loopback server (
src/server.ts): the main process starts a local HTTP server that serves the home UI under/home/, the vendored@kinora/trace-viewerdist/(unmodified) under/trace/, and local zips via/file?path=with Range/206 support (mandatory: the trace SW reads zips via Range GETs). Renderers are loaded from this loopback server, neverfile://, so the SW + Range behave exactly as on the web. Resources come from workspace deps in dev,resourcesPath/<name>when packaged. - Entry (
src/main.ts): owns the home + viewerBrowserWindows, IPC handlers, and probes. A trace can arrive three ways - File > Open Trace, drag-drop, or a.zippath arg / macOSopen-file. - Account / dashboard:
account.ts(email+password sign-in),device.ts(device-grant flow, see auth section),config.ts(server + web-origin URLs come from the build -api/app.kinora.devwhen packaged, localhost in dev; only the bearer token (via ElectronsafeStorage) and per-project repo paths persist - URLs are never read back from disk),trpc.ts(typed dashboard client reusing the server'sAppRoutertype).bridge.tsdefines the IPC contract;home-preload.tsexposes it aswindow.kinora. - Local re-run (
runner.ts): re-runs a single test locally with the repo's own Playwright (falls back tonpx --no-install, never auto-installs) and watches for the producedtrace.zip.resolve.tsmaps a Playwright-reportedfile(relative to itsrootDir, not repo root) to the real file and the nearestplaywright.config.*dir (the re-run cwd).editor.tsopens a file atline:colincode/forks (overrideKINORA_EDITOR_CMD); GUI launch strips PATH, so it augments PATH with the usual install dirs. - Probes:
KINORA_DESKTOP_PROBE/KINORA_HOME_PROBE/KINORA_DEVICE_PROBErender headless, assert, and exit 0/1 (pnpm probe,pnpm probe:home).build/typecheck/testrun in root CI. - Release + auto-update: the Desktop Release workflow (
.github/workflows/desktop-release.yml, manualworkflow_dispatch) builds on a matrix of runners - macOS (--mac, dmg+zip, archarm64+x64, signed viaCSC_LINK/CSC_KEY_PASSWORD+ notarized viaAPPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID), Windows (--win, nsis), and Linux (--linux, AppImage). Win/Linux ship unsigned for now (no code-signing cert; signing env is gated to the mac job). A one-shotdraftjob pre-creates the draft GitHub Release for the version inpackage.jsonso the concurrent matrix jobs upload into it instead of racing to create it. Auto-update is wired viaelectron-updateragainst that GitHub feed (publish: githubinelectron-builder.yml): background download + an in-app "Restart to update" pill (main.ts-> bridge ->AppHeader.vue), install-on-quit fallback. The branded install DMG (build/dmg-background.png+build/icon.icnsvolume icon, laid out inelectron-builder.yml) needs electron-builder ≥26 - itsdmgbuildengine is the one whose.DS_StoremacOS 26 (Tahoe) actually honors; the v25 writer renders a blank background there.
Two per-package Dockerfiles, both built from the repo root (workspace context):
packages/web/Dockerfile: buildsweb+trace-viewerstatic output, serves both from nginx (dashboard SPA at/, viewer at/trace).VITE_KINORA_SERVER_URLis baked at build time via--build-arg;VITE_KINORA_VIEWER_URLdefaults to/trace/(same origin).packages/server/Dockerfile: the Node/tsxserver image. Itsmigrate.mjsis also the entrypoint for the one-shot migration step.
selfhost/ is the shipped single-origin self-host bundle: docker-compose.yml (Postgres + one-shot migrate + server + web) and nginx.conf (the web container reverse-proxies /api, /trpc, /artifacts to the server, so there's no CORS and the cookie stays host-only). Configured by selfhost/.env; runs KINORA_CLOUD=false.
landing/ is a standalone Astro site (its own pnpm workspace + lockfile + Dockerfile, not part of the root packages/* workspace). Build/dev separately from landing/ (pnpm dev / pnpm build).
- ESLint is
@antfu/eslint-config(vue + typescript). No Prettier; lint owns formatting. - ESM only (
"type": "module"),.tsextension imports allowed (allowImportingTsExtensions). Exception:packages/desktopemits its main/preload as CommonJS.cjs(Electron requirement, see Desktop app) even though the package itself is"type": "module". - Libs build with
tsdown; their publishedexportspoint atdist/, but in-repoexportspoint atsrc/so the workspace consumes TypeScript source directly.