mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
Merge branch 'master' into hf/advisor-disable-legacy-api-keys
This commit is contained in:
9200 files changed
+637070
-1094430
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Generation Info
|
||||
|
||||
- **Source:** `sources/vitest`
|
||||
- **Git SHA:** `4a7321e10672f00f0bb698823a381c2cc245b8f7`
|
||||
- **Generated:** 2026-01-28
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: vitest
|
||||
description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures.
|
||||
metadata:
|
||||
author: Anthony Fu
|
||||
version: "2026.1.28"
|
||||
source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills
|
||||
---
|
||||
|
||||
Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app.
|
||||
|
||||
**Key Features:**
|
||||
- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates
|
||||
- Jest-compatible: Drop-in replacement for most Jest test suites
|
||||
- Smart watch mode: Only reruns affected tests based on module graph
|
||||
- Native ESM, TypeScript, JSX support without configuration
|
||||
- Multi-threaded workers for parallel test execution
|
||||
- Built-in coverage via V8 or Istanbul
|
||||
- Snapshot testing, mocking, and spy utilities
|
||||
|
||||
> The skill is based on Vitest 3.x, generated at 2026-01-28.
|
||||
|
||||
## Core
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) |
|
||||
| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) |
|
||||
| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) |
|
||||
| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) |
|
||||
| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) |
|
||||
| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) |
|
||||
| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) |
|
||||
| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) |
|
||||
| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) |
|
||||
| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) |
|
||||
| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) |
|
||||
|
||||
## Advanced
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) |
|
||||
| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) |
|
||||
| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) |
|
||||
| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) |
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
name: test-environments
|
||||
description: Configure environments like jsdom, happy-dom for browser APIs
|
||||
---
|
||||
|
||||
# Test Environments
|
||||
|
||||
## Available Environments
|
||||
|
||||
- `node` (default) - Node.js environment
|
||||
- `jsdom` - Browser-like with DOM APIs
|
||||
- `happy-dom` - Faster alternative to jsdom
|
||||
- `edge-runtime` - Vercel Edge Runtime
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
|
||||
// Environment-specific options
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: 'http://localhost',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Installing Environment Packages
|
||||
|
||||
```bash
|
||||
# jsdom
|
||||
npm i -D jsdom
|
||||
|
||||
# happy-dom (faster, fewer APIs)
|
||||
npm i -D happy-dom
|
||||
```
|
||||
|
||||
## Per-File Environment
|
||||
|
||||
Use magic comment at top of file:
|
||||
|
||||
```ts
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('DOM test', () => {
|
||||
const div = document.createElement('div')
|
||||
expect(div).toBeInstanceOf(HTMLDivElement)
|
||||
})
|
||||
```
|
||||
|
||||
## jsdom Environment
|
||||
|
||||
Full browser environment simulation:
|
||||
|
||||
```ts
|
||||
// @vitest-environment jsdom
|
||||
|
||||
test('DOM manipulation', () => {
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
|
||||
const app = document.getElementById('app')
|
||||
app.textContent = 'Hello'
|
||||
|
||||
expect(app.textContent).toBe('Hello')
|
||||
})
|
||||
|
||||
test('window APIs', () => {
|
||||
expect(window.location.href).toBeDefined()
|
||||
expect(localStorage).toBeDefined()
|
||||
})
|
||||
```
|
||||
|
||||
### jsdom Options
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: 'http://localhost:3000',
|
||||
html: '<!DOCTYPE html><html><body></body></html>',
|
||||
userAgent: 'custom-agent',
|
||||
resources: 'usable',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## happy-dom Environment
|
||||
|
||||
Faster but fewer APIs:
|
||||
|
||||
```ts
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
test('basic DOM', () => {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'test'
|
||||
expect(el.className).toBe('test')
|
||||
})
|
||||
```
|
||||
|
||||
## Multiple Environments per Project
|
||||
|
||||
Use projects for different environments:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'dom',
|
||||
include: ['tests/dom/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Environment
|
||||
|
||||
Create custom environment package:
|
||||
|
||||
```ts
|
||||
// vitest-environment-custom/index.ts
|
||||
import type { Environment } from 'vitest/runtime'
|
||||
|
||||
export default <Environment>{
|
||||
name: 'custom',
|
||||
viteEnvironment: 'ssr', // or 'client'
|
||||
|
||||
setup() {
|
||||
// Setup global state
|
||||
globalThis.myGlobal = 'value'
|
||||
|
||||
return {
|
||||
teardown() {
|
||||
delete globalThis.myGlobal
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use with:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'custom',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Environment with VM
|
||||
|
||||
For full isolation:
|
||||
|
||||
```ts
|
||||
export default <Environment>{
|
||||
name: 'isolated',
|
||||
viteEnvironment: 'ssr',
|
||||
|
||||
async setupVM() {
|
||||
const vm = await import('node:vm')
|
||||
const context = vm.createContext()
|
||||
|
||||
return {
|
||||
getVmContext() {
|
||||
return context
|
||||
},
|
||||
teardown() {},
|
||||
}
|
||||
},
|
||||
|
||||
setup() {
|
||||
return { teardown() {} }
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Mode (Separate from Environments)
|
||||
|
||||
For real browser testing, use Vitest Browser Mode:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium', // or 'firefox', 'webkit'
|
||||
provider: 'playwright',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## CSS and Assets
|
||||
|
||||
In jsdom/happy-dom, configure CSS handling:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
css: true, // Process CSS
|
||||
|
||||
// Or with options
|
||||
css: {
|
||||
include: /\.module\.css$/,
|
||||
modules: {
|
||||
classNameStrategy: 'non-scoped',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Fixing External Dependencies
|
||||
|
||||
If external deps fail with CSS/asset errors:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['problematic-package'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Default is `node` - no browser APIs
|
||||
- Use `jsdom` for full browser simulation
|
||||
- Use `happy-dom` for faster tests with basic DOM
|
||||
- Per-file environment via `// @vitest-environment` comment
|
||||
- Use projects for multiple environment configurations
|
||||
- Browser Mode is for real browser testing, not environment
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/environment.html
|
||||
-->
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: projects-workspaces
|
||||
description: Multi-project configuration for monorepos and different test types
|
||||
---
|
||||
|
||||
# Projects
|
||||
|
||||
Run different test configurations in the same Vitest process.
|
||||
|
||||
## Basic Projects Setup
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
// Glob patterns for config files
|
||||
'packages/*',
|
||||
|
||||
// Inline config
|
||||
{
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'integration',
|
||||
include: ['tests/integration/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Monorepo Pattern
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
// Each package has its own vitest.config.ts
|
||||
'packages/core',
|
||||
'packages/cli',
|
||||
'packages/utils',
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Package config:
|
||||
|
||||
```ts
|
||||
// packages/core/vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'core',
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Different Environments
|
||||
|
||||
Run same tests in different environments:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'happy-dom',
|
||||
root: './shared-tests',
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./setup.happy-dom.ts'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'node',
|
||||
root: './shared-tests',
|
||||
environment: 'node',
|
||||
setupFiles: ['./setup.node.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Browser + Node Projects
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'browser',
|
||||
include: ['tests/browser/**/*.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Shared Configuration
|
||||
|
||||
```ts
|
||||
// vitest.shared.ts
|
||||
export const sharedConfig = {
|
||||
testTimeout: 10000,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
}
|
||||
|
||||
// vitest.config.ts
|
||||
import { sharedConfig } from './vitest.shared'
|
||||
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
...sharedConfig,
|
||||
name: 'unit',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
...sharedConfig,
|
||||
name: 'e2e',
|
||||
include: ['tests/e2e/**/*.test.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Project-Specific Dependencies
|
||||
|
||||
Each project can have different dependencies inlined:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'project-a',
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['package-a'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Running Specific Projects
|
||||
|
||||
```bash
|
||||
# Run specific project
|
||||
vitest --project unit
|
||||
vitest --project integration
|
||||
|
||||
# Multiple projects
|
||||
vitest --project unit --project e2e
|
||||
|
||||
# Exclude project
|
||||
vitest --project.ignore browser
|
||||
```
|
||||
|
||||
## Providing Values to Projects
|
||||
|
||||
Share values from config to tests:
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'staging',
|
||||
provide: {
|
||||
apiUrl: 'https://staging.api.com',
|
||||
debug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'production',
|
||||
provide: {
|
||||
apiUrl: 'https://api.com',
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// In tests, use inject
|
||||
import { inject } from 'vitest'
|
||||
|
||||
test('uses correct api', () => {
|
||||
const url = inject('apiUrl')
|
||||
expect(url).toContain('api.com')
|
||||
})
|
||||
```
|
||||
|
||||
## With Fixtures
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
apiUrl: ['/default', { injected: true }],
|
||||
})
|
||||
|
||||
test('uses injected url', ({ apiUrl }) => {
|
||||
// apiUrl comes from project's provide config
|
||||
})
|
||||
```
|
||||
|
||||
## Project Isolation
|
||||
|
||||
Each project runs in its own thread pool by default:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'isolated',
|
||||
isolate: true, // Full isolation
|
||||
pool: 'forks',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Global Setup per Project
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'with-db',
|
||||
globalSetup: ['./tests/db-setup.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Projects run in same Vitest process
|
||||
- Each project can have different environment, config
|
||||
- Use glob patterns for monorepo packages
|
||||
- Run specific projects with `--project` flag
|
||||
- Use `provide` to inject config values into tests
|
||||
- Projects inherit from root config unless overridden
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/projects.html
|
||||
-->
|
||||
@@ -0,0 +1,237 @@
|
||||
---
|
||||
name: type-testing
|
||||
description: Test TypeScript types with expectTypeOf and assertType
|
||||
---
|
||||
|
||||
# Type Testing
|
||||
|
||||
Test TypeScript types without runtime execution.
|
||||
|
||||
## Setup
|
||||
|
||||
Type tests use `.test-d.ts` extension:
|
||||
|
||||
```ts
|
||||
// math.test-d.ts
|
||||
import { expectTypeOf } from 'vitest'
|
||||
import { add } from './math'
|
||||
|
||||
test('add returns number', () => {
|
||||
expectTypeOf(add).returns.toBeNumber()
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
typecheck: {
|
||||
enabled: true,
|
||||
|
||||
// Only type check
|
||||
only: false,
|
||||
|
||||
// Checker: 'tsc' or 'vue-tsc'
|
||||
checker: 'tsc',
|
||||
|
||||
// Include patterns
|
||||
include: ['**/*.test-d.ts'],
|
||||
|
||||
// tsconfig to use
|
||||
tsconfig: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## expectTypeOf API
|
||||
|
||||
```ts
|
||||
import { expectTypeOf } from 'vitest'
|
||||
|
||||
// Basic type checks
|
||||
expectTypeOf<string>().toBeString()
|
||||
expectTypeOf<number>().toBeNumber()
|
||||
expectTypeOf<boolean>().toBeBoolean()
|
||||
expectTypeOf<null>().toBeNull()
|
||||
expectTypeOf<undefined>().toBeUndefined()
|
||||
expectTypeOf<void>().toBeVoid()
|
||||
expectTypeOf<never>().toBeNever()
|
||||
expectTypeOf<any>().toBeAny()
|
||||
expectTypeOf<unknown>().toBeUnknown()
|
||||
expectTypeOf<object>().toBeObject()
|
||||
expectTypeOf<Function>().toBeFunction()
|
||||
expectTypeOf<[]>().toBeArray()
|
||||
expectTypeOf<symbol>().toBeSymbol()
|
||||
```
|
||||
|
||||
## Value Type Checking
|
||||
|
||||
```ts
|
||||
const value = 'hello'
|
||||
expectTypeOf(value).toBeString()
|
||||
|
||||
const obj = { name: 'test', count: 42 }
|
||||
expectTypeOf(obj).toMatchTypeOf<{ name: string }>()
|
||||
expectTypeOf(obj).toHaveProperty('name')
|
||||
```
|
||||
|
||||
## Function Types
|
||||
|
||||
```ts
|
||||
function greet(name: string): string {
|
||||
return `Hello, ${name}`
|
||||
}
|
||||
|
||||
expectTypeOf(greet).toBeFunction()
|
||||
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>()
|
||||
expectTypeOf(greet).returns.toBeString()
|
||||
|
||||
// Parameter checking
|
||||
expectTypeOf(greet).parameter(0).toBeString()
|
||||
```
|
||||
|
||||
## Object Types
|
||||
|
||||
```ts
|
||||
interface User {
|
||||
id: number
|
||||
name: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
expectTypeOf<User>().toHaveProperty('id')
|
||||
expectTypeOf<User>().toHaveProperty('name').toBeString()
|
||||
|
||||
// Check shape
|
||||
expectTypeOf({ id: 1, name: 'test' }).toMatchTypeOf<User>()
|
||||
```
|
||||
|
||||
## Equality vs Matching
|
||||
|
||||
```ts
|
||||
interface A { x: number }
|
||||
interface B { x: number; y: string }
|
||||
|
||||
// toMatchTypeOf - subset matching
|
||||
expectTypeOf<B>().toMatchTypeOf<A>() // B extends A
|
||||
|
||||
// toEqualTypeOf - exact match
|
||||
expectTypeOf<A>().not.toEqualTypeOf<B>() // Not exact match
|
||||
expectTypeOf<A>().toEqualTypeOf<{ x: number }>() // Exact match
|
||||
```
|
||||
|
||||
## Branded Types
|
||||
|
||||
```ts
|
||||
type UserId = number & { __brand: 'UserId' }
|
||||
type PostId = number & { __brand: 'PostId' }
|
||||
|
||||
expectTypeOf<UserId>().not.toEqualTypeOf<PostId>()
|
||||
expectTypeOf<UserId>().not.toEqualTypeOf<number>()
|
||||
```
|
||||
|
||||
## Generic Types
|
||||
|
||||
```ts
|
||||
function identity<T>(value: T): T {
|
||||
return value
|
||||
}
|
||||
|
||||
expectTypeOf(identity<string>).returns.toBeString()
|
||||
expectTypeOf(identity<number>).returns.toBeNumber()
|
||||
```
|
||||
|
||||
## Nullable Types
|
||||
|
||||
```ts
|
||||
type MaybeString = string | null | undefined
|
||||
|
||||
expectTypeOf<MaybeString>().toBeNullable()
|
||||
expectTypeOf<string>().not.toBeNullable()
|
||||
```
|
||||
|
||||
## assertType
|
||||
|
||||
Assert a value matches a type (no assertion at runtime):
|
||||
|
||||
```ts
|
||||
import { assertType } from 'vitest'
|
||||
|
||||
function getUser(): User | null {
|
||||
return { id: 1, name: 'test' }
|
||||
}
|
||||
|
||||
test('returns user', () => {
|
||||
const result = getUser()
|
||||
|
||||
// @ts-expect-error - should fail type check
|
||||
assertType<string>(result)
|
||||
|
||||
// Correct type
|
||||
assertType<User | null>(result)
|
||||
})
|
||||
```
|
||||
|
||||
## Using @ts-expect-error
|
||||
|
||||
Test that code produces type error:
|
||||
|
||||
```ts
|
||||
test('rejects wrong types', () => {
|
||||
function requireString(s: string) {}
|
||||
|
||||
// @ts-expect-error - number not assignable to string
|
||||
requireString(123)
|
||||
})
|
||||
```
|
||||
|
||||
## Running Type Tests
|
||||
|
||||
```bash
|
||||
# Run type tests
|
||||
vitest typecheck
|
||||
|
||||
# Run alongside unit tests
|
||||
vitest --typecheck
|
||||
|
||||
# Type tests only
|
||||
vitest --typecheck.only
|
||||
```
|
||||
|
||||
## Mixed Test Files
|
||||
|
||||
Combine runtime and type tests:
|
||||
|
||||
```ts
|
||||
// user.test.ts
|
||||
import { describe, expect, expectTypeOf, test } from 'vitest'
|
||||
import { createUser } from './user'
|
||||
|
||||
describe('createUser', () => {
|
||||
test('runtime: creates user', () => {
|
||||
const user = createUser('John')
|
||||
expect(user.name).toBe('John')
|
||||
})
|
||||
|
||||
test('types: returns User type', () => {
|
||||
expectTypeOf(createUser).returns.toMatchTypeOf<{ name: string }>()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `.test-d.ts` for type-only tests
|
||||
- `expectTypeOf` for type assertions
|
||||
- `toMatchTypeOf` for subset matching
|
||||
- `toEqualTypeOf` for exact type matching
|
||||
- Use `@ts-expect-error` to test type errors
|
||||
- Run with `vitest typecheck` or `--typecheck`
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/testing-types.html
|
||||
- https://vitest.dev/api/expect-typeof.html
|
||||
-->
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: vi-utilities
|
||||
description: vi helper for mocking, timers, utilities
|
||||
---
|
||||
|
||||
# Vi Utilities
|
||||
|
||||
The `vi` helper provides mocking and utility functions.
|
||||
|
||||
```ts
|
||||
import { vi } from 'vitest'
|
||||
```
|
||||
|
||||
## Mock Functions
|
||||
|
||||
```ts
|
||||
// Create mock
|
||||
const fn = vi.fn()
|
||||
const fnWithImpl = vi.fn((x) => x * 2)
|
||||
|
||||
// Check if mock
|
||||
vi.isMockFunction(fn) // true
|
||||
|
||||
// Mock methods
|
||||
fn.mockReturnValue(42)
|
||||
fn.mockReturnValueOnce(1)
|
||||
fn.mockResolvedValue(data)
|
||||
fn.mockRejectedValue(error)
|
||||
fn.mockImplementation(() => 'result')
|
||||
fn.mockImplementationOnce(() => 'once')
|
||||
|
||||
// Clear/reset
|
||||
fn.mockClear() // Clear call history
|
||||
fn.mockReset() // Clear history + implementation
|
||||
fn.mockRestore() // Restore original (for spies)
|
||||
```
|
||||
|
||||
## Spying
|
||||
|
||||
```ts
|
||||
const obj = { method: () => 'original' }
|
||||
|
||||
const spy = vi.spyOn(obj, 'method')
|
||||
obj.method()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
|
||||
// Mock implementation
|
||||
spy.mockReturnValue('mocked')
|
||||
|
||||
// Spy on getter/setter
|
||||
vi.spyOn(obj, 'prop', 'get').mockReturnValue('value')
|
||||
```
|
||||
|
||||
## Module Mocking
|
||||
|
||||
```ts
|
||||
// Hoisted to top of file
|
||||
vi.mock('./module', () => ({
|
||||
fn: vi.fn(),
|
||||
}))
|
||||
|
||||
// Partial mock
|
||||
vi.mock('./module', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
specificFn: vi.fn(),
|
||||
}))
|
||||
|
||||
// Spy mode - keep implementation
|
||||
vi.mock('./module', { spy: true })
|
||||
|
||||
// Import actual module inside mock
|
||||
const actual = await vi.importActual('./module')
|
||||
|
||||
// Import as mock
|
||||
const mocked = await vi.importMock('./module')
|
||||
```
|
||||
|
||||
## Dynamic Mocking
|
||||
|
||||
```ts
|
||||
// Not hoisted - use with dynamic imports
|
||||
vi.doMock('./config', () => ({ key: 'value' }))
|
||||
const config = await import('./config')
|
||||
|
||||
// Unmock
|
||||
vi.doUnmock('./config')
|
||||
vi.unmock('./module') // Hoisted
|
||||
```
|
||||
|
||||
## Reset Modules
|
||||
|
||||
```ts
|
||||
// Clear module cache
|
||||
vi.resetModules()
|
||||
|
||||
// Wait for dynamic imports
|
||||
await vi.dynamicImportSettled()
|
||||
```
|
||||
|
||||
## Fake Timers
|
||||
|
||||
```ts
|
||||
vi.useFakeTimers()
|
||||
|
||||
setTimeout(() => console.log('done'), 1000)
|
||||
|
||||
// Advance time
|
||||
vi.advanceTimersByTime(1000)
|
||||
vi.advanceTimersByTimeAsync(1000) // For async callbacks
|
||||
vi.advanceTimersToNextTimer()
|
||||
vi.advanceTimersToNextFrame() // requestAnimationFrame
|
||||
|
||||
// Run all timers
|
||||
vi.runAllTimers()
|
||||
vi.runAllTimersAsync()
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
// Clear timers
|
||||
vi.clearAllTimers()
|
||||
|
||||
// Check state
|
||||
vi.getTimerCount()
|
||||
vi.isFakeTimers()
|
||||
|
||||
// Restore
|
||||
vi.useRealTimers()
|
||||
```
|
||||
|
||||
## Mock Date/Time
|
||||
|
||||
```ts
|
||||
vi.setSystemTime(new Date('2024-01-01'))
|
||||
expect(new Date().getFullYear()).toBe(2024)
|
||||
|
||||
vi.getMockedSystemTime() // Get mocked date
|
||||
vi.getRealSystemTime() // Get real time (ms)
|
||||
```
|
||||
|
||||
## Global/Env Mocking
|
||||
|
||||
```ts
|
||||
// Stub global
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
// Stub environment
|
||||
vi.stubEnv('API_KEY', 'test')
|
||||
vi.stubEnv('NODE_ENV', 'test')
|
||||
vi.unstubAllEnvs()
|
||||
```
|
||||
|
||||
## Hoisted Code
|
||||
|
||||
Run code before imports:
|
||||
|
||||
```ts
|
||||
const mock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./module', () => ({
|
||||
fn: mock, // Can reference hoisted variable
|
||||
}))
|
||||
```
|
||||
|
||||
## Waiting Utilities
|
||||
|
||||
```ts
|
||||
// Wait for callback to succeed
|
||||
await vi.waitFor(async () => {
|
||||
const el = document.querySelector('.loaded')
|
||||
expect(el).toBeTruthy()
|
||||
}, { timeout: 5000, interval: 100 })
|
||||
|
||||
// Wait for truthy value
|
||||
const element = await vi.waitUntil(
|
||||
() => document.querySelector('.loaded'),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
```
|
||||
|
||||
## Mock Object
|
||||
|
||||
Mock all methods of an object:
|
||||
|
||||
```ts
|
||||
const original = {
|
||||
method: () => 'real',
|
||||
nested: { fn: () => 'nested' },
|
||||
}
|
||||
|
||||
const mocked = vi.mockObject(original)
|
||||
mocked.method() // undefined (mocked)
|
||||
mocked.method.mockReturnValue('mocked')
|
||||
|
||||
// Spy mode
|
||||
const spied = vi.mockObject(original, { spy: true })
|
||||
spied.method() // 'real'
|
||||
expect(spied.method).toHaveBeenCalled()
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
```ts
|
||||
vi.setConfig({
|
||||
testTimeout: 10_000,
|
||||
hookTimeout: 10_000,
|
||||
})
|
||||
|
||||
vi.resetConfig()
|
||||
```
|
||||
|
||||
## Global Mock Management
|
||||
|
||||
```ts
|
||||
vi.clearAllMocks() // Clear all mock call history
|
||||
vi.resetAllMocks() // Reset + clear implementation
|
||||
vi.restoreAllMocks() // Restore originals (spies)
|
||||
```
|
||||
|
||||
## vi.mocked Type Helper
|
||||
|
||||
TypeScript helper for mocked values:
|
||||
|
||||
```ts
|
||||
import { myFn } from './module'
|
||||
vi.mock('./module')
|
||||
|
||||
// Type as mock
|
||||
vi.mocked(myFn).mockReturnValue('typed')
|
||||
|
||||
// Deep mocking
|
||||
vi.mocked(myModule, { deep: true })
|
||||
|
||||
// Partial mock typing
|
||||
vi.mocked(fn, { partial: true }).mockResolvedValue({ ok: true })
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- `vi.mock` is hoisted - use `vi.doMock` for dynamic mocking
|
||||
- `vi.hoisted` lets you reference variables in mock factories
|
||||
- Use `vi.spyOn` to spy on existing methods
|
||||
- Fake timers require explicit setup and teardown
|
||||
- `vi.waitFor` retries until assertion passes
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/api/vi.html
|
||||
-->
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: vitest-cli
|
||||
description: Command line interface commands and options
|
||||
---
|
||||
|
||||
# Command Line Interface
|
||||
|
||||
## Commands
|
||||
|
||||
### `vitest`
|
||||
|
||||
Start Vitest in watch mode (dev) or run mode (CI):
|
||||
|
||||
```bash
|
||||
vitest # Watch mode in dev, run mode in CI
|
||||
vitest foobar # Run tests containing "foobar" in path
|
||||
vitest basic/foo.test.ts:10 # Run specific test by file and line number
|
||||
```
|
||||
|
||||
### `vitest run`
|
||||
|
||||
Run tests once without watch mode:
|
||||
|
||||
```bash
|
||||
vitest run
|
||||
vitest run --coverage
|
||||
```
|
||||
|
||||
### `vitest watch`
|
||||
|
||||
Explicitly start watch mode:
|
||||
|
||||
```bash
|
||||
vitest watch
|
||||
```
|
||||
|
||||
### `vitest related`
|
||||
|
||||
Run tests that import specific files (useful with lint-staged):
|
||||
|
||||
```bash
|
||||
vitest related src/index.ts src/utils.ts --run
|
||||
```
|
||||
|
||||
### `vitest bench`
|
||||
|
||||
Run only benchmark tests:
|
||||
|
||||
```bash
|
||||
vitest bench
|
||||
```
|
||||
|
||||
### `vitest list`
|
||||
|
||||
List all matching tests without running them:
|
||||
|
||||
```bash
|
||||
vitest list # List test names
|
||||
vitest list --json # Output as JSON
|
||||
vitest list --filesOnly # List only test files
|
||||
```
|
||||
|
||||
### `vitest init`
|
||||
|
||||
Initialize project setup:
|
||||
|
||||
```bash
|
||||
vitest init browser # Set up browser testing
|
||||
```
|
||||
|
||||
## Common Options
|
||||
|
||||
```bash
|
||||
# Configuration
|
||||
--config <path> # Path to config file
|
||||
--project <name> # Run specific project
|
||||
|
||||
# Filtering
|
||||
--testNamePattern, -t # Run tests matching pattern
|
||||
--changed # Run tests for changed files
|
||||
--changed HEAD~1 # Tests for last commit changes
|
||||
|
||||
# Reporters
|
||||
--reporter <name> # default, verbose, dot, json, html
|
||||
--reporter=html --outputFile=report.html
|
||||
|
||||
# Coverage
|
||||
--coverage # Enable coverage
|
||||
--coverage.provider v8 # Use v8 provider
|
||||
--coverage.reporter text,html
|
||||
|
||||
# Execution
|
||||
--shard <index>/<count> # Split tests across machines
|
||||
--bail <n> # Stop after n failures
|
||||
--retry <n> # Retry failed tests n times
|
||||
--sequence.shuffle # Randomize test order
|
||||
|
||||
# Watch mode
|
||||
--no-watch # Disable watch mode
|
||||
--standalone # Start without running tests
|
||||
|
||||
# Environment
|
||||
--environment <env> # jsdom, happy-dom, node
|
||||
--globals # Enable global APIs
|
||||
|
||||
# Debugging
|
||||
--inspect # Enable Node inspector
|
||||
--inspect-brk # Break on start
|
||||
|
||||
# Output
|
||||
--silent # Suppress console output
|
||||
--no-color # Disable colors
|
||||
```
|
||||
|
||||
## Package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sharding for CI
|
||||
|
||||
Split tests across multiple machines:
|
||||
|
||||
```bash
|
||||
# Machine 1
|
||||
vitest run --shard=1/3 --reporter=blob
|
||||
|
||||
# Machine 2
|
||||
vitest run --shard=2/3 --reporter=blob
|
||||
|
||||
# Machine 3
|
||||
vitest run --shard=3/3 --reporter=blob
|
||||
|
||||
# Merge reports
|
||||
vitest --merge-reports --reporter=junit
|
||||
```
|
||||
|
||||
## Watch Mode Keyboard Shortcuts
|
||||
|
||||
In watch mode, press:
|
||||
- `a` - Run all tests
|
||||
- `f` - Run only failed tests
|
||||
- `u` - Update snapshots
|
||||
- `p` - Filter by filename pattern
|
||||
- `t` - Filter by test name pattern
|
||||
- `q` - Quit
|
||||
|
||||
## Key Points
|
||||
|
||||
- Watch mode is default in dev, run mode in CI (when `process.env.CI` is set)
|
||||
- Use `--run` flag to ensure single run (important for lint-staged)
|
||||
- Both camelCase (`--testTimeout`) and kebab-case (`--test-timeout`) work
|
||||
- Boolean options can be negated with `--no-` prefix
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/cli.html
|
||||
-->
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
name: vitest-configuration
|
||||
description: Configure Vitest with vite.config.ts or vitest.config.ts
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
Vitest reads configuration from `vitest.config.ts` or `vite.config.ts`. It shares the same config format as Vite.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// test options
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Using with Existing Vite Config
|
||||
|
||||
Add Vitest types reference and use the `test` property:
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Merging Configs
|
||||
|
||||
If you have separate config files, use `mergeConfig`:
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(viteConfig, defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
## Common Options
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
// Enable global APIs (describe, it, expect) without imports
|
||||
globals: true,
|
||||
|
||||
// Test environment: 'node', 'jsdom', 'happy-dom'
|
||||
environment: 'node',
|
||||
|
||||
// Setup files to run before each test file
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
|
||||
// Include patterns for test files
|
||||
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
|
||||
// Exclude patterns
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
|
||||
// Test timeout in ms
|
||||
testTimeout: 5000,
|
||||
|
||||
// Hook timeout in ms
|
||||
hookTimeout: 10000,
|
||||
|
||||
// Enable watch mode by default
|
||||
watch: true,
|
||||
|
||||
// Coverage configuration
|
||||
coverage: {
|
||||
provider: 'v8', // or 'istanbul'
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
},
|
||||
|
||||
// Run tests in isolation (each file in separate process)
|
||||
isolate: true,
|
||||
|
||||
// Pool for running tests: 'threads', 'forks', 'vmThreads'
|
||||
pool: 'threads',
|
||||
|
||||
// Number of threads/processes
|
||||
poolOptions: {
|
||||
threads: {
|
||||
maxThreads: 4,
|
||||
minThreads: 1,
|
||||
},
|
||||
},
|
||||
|
||||
// Automatically clear mocks between tests
|
||||
clearMocks: true,
|
||||
|
||||
// Restore mocks between tests
|
||||
restoreMocks: true,
|
||||
|
||||
// Retry failed tests
|
||||
retry: 0,
|
||||
|
||||
// Stop after first failure
|
||||
bail: 0,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Conditional Configuration
|
||||
|
||||
Use `mode` or `process.env.VITEST` for test-specific config:
|
||||
|
||||
```ts
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: mode === 'test' ? [] : [myPlugin()],
|
||||
test: {
|
||||
// test options
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
## Projects (Monorepos)
|
||||
|
||||
Run different configurations in the same Vitest process:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'packages/*',
|
||||
{
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'integration',
|
||||
include: ['tests/integration/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Vitest uses Vite's transformation pipeline - same `resolve.alias`, plugins work
|
||||
- `vitest.config.ts` takes priority over `vite.config.ts`
|
||||
- Use `--config` flag to specify a custom config path
|
||||
- `process.env.VITEST` is set to `true` when running tests
|
||||
- Test config uses `test` property, rest is Vite config
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/#configuring-vitest
|
||||
- https://vitest.dev/config/
|
||||
-->
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: describe-api
|
||||
description: describe/suite for grouping tests into logical blocks
|
||||
---
|
||||
|
||||
# Describe API
|
||||
|
||||
Group related tests into suites for organization and shared setup.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
describe('Math', () => {
|
||||
test('adds numbers', () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
|
||||
test('subtracts numbers', () => {
|
||||
expect(3 - 1).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Alias: suite
|
||||
import { suite } from 'vitest'
|
||||
suite('equivalent to describe', () => {})
|
||||
```
|
||||
|
||||
## Nested Suites
|
||||
|
||||
```ts
|
||||
describe('User', () => {
|
||||
describe('when logged in', () => {
|
||||
test('shows dashboard', () => {})
|
||||
test('can update profile', () => {})
|
||||
})
|
||||
|
||||
describe('when logged out', () => {
|
||||
test('shows login page', () => {})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Suite Options
|
||||
|
||||
```ts
|
||||
// All tests inherit options
|
||||
describe('slow tests', { timeout: 30_000 }, () => {
|
||||
test('test 1', () => {}) // 30s timeout
|
||||
test('test 2', () => {}) // 30s timeout
|
||||
})
|
||||
```
|
||||
|
||||
## Suite Modifiers
|
||||
|
||||
### Skip Suites
|
||||
|
||||
```ts
|
||||
describe.skip('skipped suite', () => {
|
||||
test('wont run', () => {})
|
||||
})
|
||||
|
||||
// Conditional
|
||||
describe.skipIf(process.env.CI)('not in CI', () => {})
|
||||
describe.runIf(!process.env.CI)('only local', () => {})
|
||||
```
|
||||
|
||||
### Focus Suites
|
||||
|
||||
```ts
|
||||
describe.only('only this suite runs', () => {
|
||||
test('runs', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
### Todo Suites
|
||||
|
||||
```ts
|
||||
describe.todo('implement later')
|
||||
```
|
||||
|
||||
### Concurrent Suites
|
||||
|
||||
```ts
|
||||
// All tests run in parallel
|
||||
describe.concurrent('parallel tests', () => {
|
||||
test('test 1', async ({ expect }) => {})
|
||||
test('test 2', async ({ expect }) => {})
|
||||
})
|
||||
```
|
||||
|
||||
### Sequential in Concurrent
|
||||
|
||||
```ts
|
||||
describe.concurrent('parallel', () => {
|
||||
test('concurrent 1', async () => {})
|
||||
|
||||
describe.sequential('must be sequential', () => {
|
||||
test('step 1', async () => {})
|
||||
test('step 2', async () => {})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Shuffle Tests
|
||||
|
||||
```ts
|
||||
describe.shuffle('random order', () => {
|
||||
test('test 1', () => {})
|
||||
test('test 2', () => {})
|
||||
test('test 3', () => {})
|
||||
})
|
||||
|
||||
// Or with option
|
||||
describe('random', { shuffle: true }, () => {})
|
||||
```
|
||||
|
||||
## Parameterized Suites
|
||||
|
||||
### describe.each
|
||||
|
||||
```ts
|
||||
describe.each([
|
||||
{ name: 'Chrome', version: 100 },
|
||||
{ name: 'Firefox', version: 90 },
|
||||
])('$name browser', ({ name, version }) => {
|
||||
test('has version', () => {
|
||||
expect(version).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### describe.for
|
||||
|
||||
```ts
|
||||
describe.for([
|
||||
['Chrome', 100],
|
||||
['Firefox', 90],
|
||||
])('%s browser', ([name, version]) => {
|
||||
test('has version', () => {
|
||||
expect(version).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Hooks in Suites
|
||||
|
||||
```ts
|
||||
describe('Database', () => {
|
||||
let db
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await createDb()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.clear()
|
||||
})
|
||||
|
||||
test('insert works', async () => {
|
||||
await db.insert({ name: 'test' })
|
||||
expect(await db.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Modifier Combinations
|
||||
|
||||
All modifiers can be chained:
|
||||
|
||||
```ts
|
||||
describe.skip.concurrent('skipped concurrent', () => {})
|
||||
describe.only.shuffle('only and shuffled', () => {})
|
||||
describe.concurrent.skip('equivalent', () => {})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Top-level tests belong to an implicit file suite
|
||||
- Nested suites inherit parent's options (timeout, retry, etc.)
|
||||
- Hooks are scoped to their suite and nested suites
|
||||
- Use `describe.concurrent` with context's `expect` for snapshots
|
||||
- Shuffle order depends on `sequence.seed` config
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/api/describe.html
|
||||
-->
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: expect-api
|
||||
description: Assertions with matchers, asymmetric matchers, and custom matchers
|
||||
---
|
||||
|
||||
# Expect API
|
||||
|
||||
Vitest uses Chai assertions with Jest-compatible API.
|
||||
|
||||
## Basic Assertions
|
||||
|
||||
```ts
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('assertions', () => {
|
||||
// Equality
|
||||
expect(1 + 1).toBe(2) // Strict equality (===)
|
||||
expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
|
||||
|
||||
// Truthiness
|
||||
expect(true).toBeTruthy()
|
||||
expect(false).toBeFalsy()
|
||||
expect(null).toBeNull()
|
||||
expect(undefined).toBeUndefined()
|
||||
expect('value').toBeDefined()
|
||||
|
||||
// Numbers
|
||||
expect(10).toBeGreaterThan(5)
|
||||
expect(10).toBeGreaterThanOrEqual(10)
|
||||
expect(5).toBeLessThan(10)
|
||||
expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
|
||||
|
||||
// Strings
|
||||
expect('hello world').toMatch(/world/)
|
||||
expect('hello').toContain('ell')
|
||||
|
||||
// Arrays
|
||||
expect([1, 2, 3]).toContain(2)
|
||||
expect([{ a: 1 }]).toContainEqual({ a: 1 })
|
||||
expect([1, 2, 3]).toHaveLength(3)
|
||||
|
||||
// Objects
|
||||
expect({ a: 1, b: 2 }).toHaveProperty('a')
|
||||
expect({ a: 1, b: 2 }).toHaveProperty('a', 1)
|
||||
expect({ a: { b: 1 } }).toHaveProperty('a.b', 1)
|
||||
expect({ a: 1 }).toMatchObject({ a: 1 })
|
||||
|
||||
// Types
|
||||
expect('string').toBeTypeOf('string')
|
||||
expect(new Date()).toBeInstanceOf(Date)
|
||||
})
|
||||
```
|
||||
|
||||
## Negation
|
||||
|
||||
```ts
|
||||
expect(1).not.toBe(2)
|
||||
expect({ a: 1 }).not.toEqual({ a: 2 })
|
||||
```
|
||||
|
||||
## Error Assertions
|
||||
|
||||
```ts
|
||||
// Sync errors - wrap in function
|
||||
expect(() => throwError()).toThrow()
|
||||
expect(() => throwError()).toThrow('message')
|
||||
expect(() => throwError()).toThrow(/pattern/)
|
||||
expect(() => throwError()).toThrow(CustomError)
|
||||
|
||||
// Async errors - use rejects
|
||||
await expect(asyncThrow()).rejects.toThrow('error')
|
||||
```
|
||||
|
||||
## Promise Assertions
|
||||
|
||||
```ts
|
||||
// Resolves
|
||||
await expect(Promise.resolve(1)).resolves.toBe(1)
|
||||
await expect(fetchData()).resolves.toEqual({ data: true })
|
||||
|
||||
// Rejects
|
||||
await expect(Promise.reject('error')).rejects.toBe('error')
|
||||
await expect(failingFetch()).rejects.toThrow()
|
||||
```
|
||||
|
||||
## Spy/Mock Assertions
|
||||
|
||||
```ts
|
||||
const fn = vi.fn()
|
||||
fn('arg1', 'arg2')
|
||||
fn('arg3')
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
|
||||
expect(fn).toHaveBeenLastCalledWith('arg3')
|
||||
expect(fn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2')
|
||||
|
||||
expect(fn).toHaveReturned()
|
||||
expect(fn).toHaveReturnedWith(value)
|
||||
```
|
||||
|
||||
## Asymmetric Matchers
|
||||
|
||||
Use inside `toEqual`, `toHaveBeenCalledWith`, etc:
|
||||
|
||||
```ts
|
||||
expect({ id: 1, name: 'test' }).toEqual({
|
||||
id: expect.any(Number),
|
||||
name: expect.any(String),
|
||||
})
|
||||
|
||||
expect({ a: 1, b: 2, c: 3 }).toEqual(
|
||||
expect.objectContaining({ a: 1 })
|
||||
)
|
||||
|
||||
expect([1, 2, 3, 4]).toEqual(
|
||||
expect.arrayContaining([1, 3])
|
||||
)
|
||||
|
||||
expect('hello world').toEqual(
|
||||
expect.stringContaining('world')
|
||||
)
|
||||
|
||||
expect('hello world').toEqual(
|
||||
expect.stringMatching(/world$/)
|
||||
)
|
||||
|
||||
expect({ value: null }).toEqual({
|
||||
value: expect.anything() // Matches anything except null/undefined
|
||||
})
|
||||
|
||||
// Negate with expect.not
|
||||
expect([1, 2]).toEqual(
|
||||
expect.not.arrayContaining([3])
|
||||
)
|
||||
```
|
||||
|
||||
## Soft Assertions
|
||||
|
||||
Continue test after failure:
|
||||
|
||||
```ts
|
||||
expect.soft(1).toBe(2) // Marks test failed but continues
|
||||
expect.soft(2).toBe(3) // Also runs
|
||||
// All failures reported at end
|
||||
```
|
||||
|
||||
## Poll Assertions
|
||||
|
||||
Retry until passes:
|
||||
|
||||
```ts
|
||||
await expect.poll(() => fetchStatus()).toBe('ready')
|
||||
|
||||
await expect.poll(
|
||||
() => document.querySelector('.element'),
|
||||
{ interval: 100, timeout: 5000 }
|
||||
).toBeTruthy()
|
||||
```
|
||||
|
||||
## Assertion Count
|
||||
|
||||
```ts
|
||||
test('async assertions', async () => {
|
||||
expect.assertions(2) // Exactly 2 assertions must run
|
||||
|
||||
await doAsync((data) => {
|
||||
expect(data).toBeDefined()
|
||||
expect(data.id).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('at least one', () => {
|
||||
expect.hasAssertions() // At least 1 assertion must run
|
||||
})
|
||||
```
|
||||
|
||||
## Extending Matchers
|
||||
|
||||
```ts
|
||||
expect.extend({
|
||||
toBeWithinRange(received, floor, ceiling) {
|
||||
const pass = received >= floor && received <= ceiling
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`expected ${received} to be within range ${floor} - ${ceiling}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
test('custom matcher', () => {
|
||||
expect(100).toBeWithinRange(90, 110)
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Assertions
|
||||
|
||||
```ts
|
||||
expect(data).toMatchSnapshot()
|
||||
expect(data).toMatchInlineSnapshot(`{ "id": 1 }`)
|
||||
await expect(result).toMatchFileSnapshot('./expected.json')
|
||||
|
||||
expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot()
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `toBe` for primitives, `toEqual` for objects/arrays
|
||||
- `toStrictEqual` checks undefined properties and array sparseness
|
||||
- Always `await` async assertions (`resolves`, `rejects`, `poll`)
|
||||
- Use context's `expect` in concurrent tests for correct tracking
|
||||
- `toThrow` requires wrapping sync code in a function
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/api/expect.html
|
||||
-->
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: lifecycle-hooks
|
||||
description: beforeEach, afterEach, beforeAll, afterAll, and around hooks
|
||||
---
|
||||
|
||||
# Lifecycle Hooks
|
||||
|
||||
## Basic Hooks
|
||||
|
||||
```ts
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, test } from 'vitest'
|
||||
|
||||
beforeAll(async () => {
|
||||
// Runs once before all tests in file/suite
|
||||
await setupDatabase()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Runs once after all tests in file/suite
|
||||
await teardownDatabase()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Runs before each test
|
||||
await clearTestData()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Runs after each test
|
||||
await cleanupMocks()
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup Return Pattern
|
||||
|
||||
Return cleanup function from `before*` hooks:
|
||||
|
||||
```ts
|
||||
beforeAll(async () => {
|
||||
const server = await startServer()
|
||||
|
||||
// Returned function runs as afterAll
|
||||
return async () => {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
const connection = await connect()
|
||||
|
||||
// Runs as afterEach
|
||||
return () => connection.close()
|
||||
})
|
||||
```
|
||||
|
||||
## Scoped Hooks
|
||||
|
||||
Hooks apply to current suite and nested suites:
|
||||
|
||||
```ts
|
||||
describe('outer', () => {
|
||||
beforeEach(() => console.log('outer before'))
|
||||
|
||||
test('test 1', () => {}) // outer before → test
|
||||
|
||||
describe('inner', () => {
|
||||
beforeEach(() => console.log('inner before'))
|
||||
|
||||
test('test 2', () => {}) // outer before → inner before → test
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Hook Timeout
|
||||
|
||||
```ts
|
||||
beforeAll(async () => {
|
||||
await slowSetup()
|
||||
}, 30_000) // 30 second timeout
|
||||
```
|
||||
|
||||
## Around Hooks
|
||||
|
||||
Wrap tests with setup/teardown context:
|
||||
|
||||
```ts
|
||||
import { aroundEach, test } from 'vitest'
|
||||
|
||||
// Wrap each test in database transaction
|
||||
aroundEach(async (runTest) => {
|
||||
await db.beginTransaction()
|
||||
await runTest() // Must be called!
|
||||
await db.rollback()
|
||||
})
|
||||
|
||||
test('insert user', async () => {
|
||||
await db.insert({ name: 'Alice' })
|
||||
// Automatically rolled back after test
|
||||
})
|
||||
```
|
||||
|
||||
### aroundAll
|
||||
|
||||
Wrap entire suite:
|
||||
|
||||
```ts
|
||||
import { aroundAll, test } from 'vitest'
|
||||
|
||||
aroundAll(async (runSuite) => {
|
||||
console.log('before all tests')
|
||||
await runSuite() // Must be called!
|
||||
console.log('after all tests')
|
||||
})
|
||||
```
|
||||
|
||||
### Multiple Around Hooks
|
||||
|
||||
Nested like onion layers:
|
||||
|
||||
```ts
|
||||
aroundEach(async (runTest) => {
|
||||
console.log('outer before')
|
||||
await runTest()
|
||||
console.log('outer after')
|
||||
})
|
||||
|
||||
aroundEach(async (runTest) => {
|
||||
console.log('inner before')
|
||||
await runTest()
|
||||
console.log('inner after')
|
||||
})
|
||||
|
||||
// Order: outer before → inner before → test → inner after → outer after
|
||||
```
|
||||
|
||||
## Test Hooks
|
||||
|
||||
Inside test body:
|
||||
|
||||
```ts
|
||||
import { onTestFailed, onTestFinished, test } from 'vitest'
|
||||
|
||||
test('with cleanup', () => {
|
||||
const db = connect()
|
||||
|
||||
// Runs after test finishes (pass or fail)
|
||||
onTestFinished(() => db.close())
|
||||
|
||||
// Only runs if test fails
|
||||
onTestFailed(({ task }) => {
|
||||
console.log('Failed:', task.result?.errors)
|
||||
})
|
||||
|
||||
db.query('SELECT * FROM users')
|
||||
})
|
||||
```
|
||||
|
||||
### Reusable Cleanup Pattern
|
||||
|
||||
```ts
|
||||
function useTestDb() {
|
||||
const db = connect()
|
||||
onTestFinished(() => db.close())
|
||||
return db
|
||||
}
|
||||
|
||||
test('query users', () => {
|
||||
const db = useTestDb()
|
||||
expect(db.query('SELECT * FROM users')).toBeDefined()
|
||||
})
|
||||
|
||||
test('query orders', () => {
|
||||
const db = useTestDb() // Fresh connection, auto-closed
|
||||
expect(db.query('SELECT * FROM orders')).toBeDefined()
|
||||
})
|
||||
```
|
||||
|
||||
## Concurrent Test Hooks
|
||||
|
||||
For concurrent tests, use context's hooks:
|
||||
|
||||
```ts
|
||||
test.concurrent('concurrent', ({ onTestFinished }) => {
|
||||
const resource = allocate()
|
||||
onTestFinished(() => resource.release())
|
||||
})
|
||||
```
|
||||
|
||||
## Extended Test Hooks
|
||||
|
||||
With `test.extend`, hooks are type-aware:
|
||||
|
||||
```ts
|
||||
const test = base.extend<{ db: Database }>({
|
||||
db: async ({}, use) => {
|
||||
const db = await createDb()
|
||||
await use(db)
|
||||
await db.close()
|
||||
},
|
||||
})
|
||||
|
||||
// These hooks know about `db` fixture
|
||||
test.beforeEach(({ db }) => {
|
||||
db.seed()
|
||||
})
|
||||
|
||||
test.afterEach(({ db }) => {
|
||||
db.clear()
|
||||
})
|
||||
```
|
||||
|
||||
## Hook Execution Order
|
||||
|
||||
Default order (stack):
|
||||
1. `beforeAll` (in order)
|
||||
2. `beforeEach` (in order)
|
||||
3. Test
|
||||
4. `afterEach` (reverse order)
|
||||
5. `afterAll` (reverse order)
|
||||
|
||||
Configure with `sequence.hooks`:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
sequence: {
|
||||
hooks: 'list', // 'stack' (default), 'list', 'parallel'
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Hooks are not called during type checking
|
||||
- Return cleanup function from `before*` to avoid `after*` duplication
|
||||
- `aroundEach`/`aroundAll` must call `runTest()`/`runSuite()`
|
||||
- `onTestFinished` always runs, even if test fails
|
||||
- Use context hooks for concurrent tests
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/api/hooks.html
|
||||
-->
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: test-api
|
||||
description: test/it function for defining tests with modifiers
|
||||
---
|
||||
|
||||
# Test API
|
||||
|
||||
## Basic Test
|
||||
|
||||
```ts
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('adds numbers', () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
|
||||
// Alias: it
|
||||
import { it } from 'vitest'
|
||||
|
||||
it('works the same', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Async Tests
|
||||
|
||||
```ts
|
||||
test('async test', async () => {
|
||||
const result = await fetchData()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
// Promises are automatically awaited
|
||||
test('returns promise', () => {
|
||||
return fetchData().then(result => {
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Test Options
|
||||
|
||||
```ts
|
||||
// Timeout (default: 5000ms)
|
||||
test('slow test', async () => {
|
||||
// ...
|
||||
}, 10_000)
|
||||
|
||||
// Or with options object
|
||||
test('with options', { timeout: 10_000, retry: 2 }, async () => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## Test Modifiers
|
||||
|
||||
### Skip Tests
|
||||
|
||||
```ts
|
||||
test.skip('skipped test', () => {
|
||||
// Won't run
|
||||
})
|
||||
|
||||
// Conditional skip
|
||||
test.skipIf(process.env.CI)('not in CI', () => {})
|
||||
test.runIf(process.env.CI)('only in CI', () => {})
|
||||
|
||||
// Dynamic skip via context
|
||||
test('dynamic skip', ({ skip }) => {
|
||||
skip(someCondition, 'reason')
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### Focus Tests
|
||||
|
||||
```ts
|
||||
test.only('only this runs', () => {
|
||||
// Other tests in file are skipped
|
||||
})
|
||||
```
|
||||
|
||||
### Todo Tests
|
||||
|
||||
```ts
|
||||
test.todo('implement later')
|
||||
|
||||
test.todo('with body', () => {
|
||||
// Not run, shows in report
|
||||
})
|
||||
```
|
||||
|
||||
### Failing Tests
|
||||
|
||||
```ts
|
||||
test.fails('expected to fail', () => {
|
||||
expect(1).toBe(2) // Test passes because assertion fails
|
||||
})
|
||||
```
|
||||
|
||||
### Concurrent Tests
|
||||
|
||||
```ts
|
||||
// Run tests in parallel
|
||||
test.concurrent('test 1', async ({ expect }) => {
|
||||
// Use context.expect for concurrent tests
|
||||
expect(await fetch1()).toBe('result')
|
||||
})
|
||||
|
||||
test.concurrent('test 2', async ({ expect }) => {
|
||||
expect(await fetch2()).toBe('result')
|
||||
})
|
||||
```
|
||||
|
||||
### Sequential Tests
|
||||
|
||||
```ts
|
||||
// Force sequential in concurrent context
|
||||
test.sequential('must run alone', async () => {})
|
||||
```
|
||||
|
||||
## Parameterized Tests
|
||||
|
||||
### test.each
|
||||
|
||||
```ts
|
||||
test.each([
|
||||
[1, 1, 2],
|
||||
[1, 2, 3],
|
||||
[2, 1, 3],
|
||||
])('add(%i, %i) = %i', (a, b, expected) => {
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
|
||||
// With objects
|
||||
test.each([
|
||||
{ a: 1, b: 1, expected: 2 },
|
||||
{ a: 1, b: 2, expected: 3 },
|
||||
])('add($a, $b) = $expected', ({ a, b, expected }) => {
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
|
||||
// Template literal
|
||||
test.each`
|
||||
a | b | expected
|
||||
${1} | ${1} | ${2}
|
||||
${1} | ${2} | ${3}
|
||||
`('add($a, $b) = $expected', ({ a, b, expected }) => {
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
### test.for
|
||||
|
||||
Preferred over `.each` - doesn't spread arrays:
|
||||
|
||||
```ts
|
||||
test.for([
|
||||
[1, 1, 2],
|
||||
[1, 2, 3],
|
||||
])('add(%i, %i) = %i', ([a, b, expected], { expect }) => {
|
||||
// Second arg is TestContext
|
||||
expect(a + b).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
## Test Context
|
||||
|
||||
First argument provides context utilities:
|
||||
|
||||
```ts
|
||||
test('with context', ({ expect, skip, task }) => {
|
||||
console.log(task.name) // Test name
|
||||
skip(someCondition) // Skip dynamically
|
||||
expect(1).toBe(1) // Context-bound expect
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Test with Fixtures
|
||||
|
||||
```ts
|
||||
import { test as base } from 'vitest'
|
||||
|
||||
const test = base.extend({
|
||||
db: async ({}, use) => {
|
||||
const db = await createDb()
|
||||
await use(db)
|
||||
await db.close()
|
||||
},
|
||||
})
|
||||
|
||||
test('query', async ({ db }) => {
|
||||
const users = await db.query('SELECT * FROM users')
|
||||
expect(users).toBeDefined()
|
||||
})
|
||||
```
|
||||
|
||||
## Retry Configuration
|
||||
|
||||
```ts
|
||||
test('flaky test', { retry: 3 }, async () => {
|
||||
// Retries up to 3 times on failure
|
||||
})
|
||||
|
||||
// Advanced retry options
|
||||
test('with delay', {
|
||||
retry: {
|
||||
count: 3,
|
||||
delay: 1000,
|
||||
condition: /timeout/i, // Only retry on timeout errors
|
||||
},
|
||||
}, async () => {})
|
||||
```
|
||||
|
||||
## Tags
|
||||
|
||||
```ts
|
||||
test('database test', { tags: ['db', 'slow'] }, async () => {})
|
||||
|
||||
// Run with: vitest --tags db
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Tests with no body are marked as `todo`
|
||||
- `test.only` throws in CI unless `allowOnly: true`
|
||||
- Use context's `expect` for concurrent tests and snapshots
|
||||
- Function name is used as test name if passed as first arg
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/api/test.html
|
||||
-->
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: concurrency-parallelism
|
||||
description: Concurrent tests, parallel execution, and sharding
|
||||
---
|
||||
|
||||
# Concurrency & Parallelism
|
||||
|
||||
## File Parallelism
|
||||
|
||||
By default, Vitest runs test files in parallel across workers:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
// Run files in parallel (default: true)
|
||||
fileParallelism: true,
|
||||
|
||||
// Number of worker threads
|
||||
maxWorkers: 4,
|
||||
minWorkers: 1,
|
||||
|
||||
// Pool type: 'threads', 'forks', 'vmThreads'
|
||||
pool: 'threads',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Concurrent Tests
|
||||
|
||||
Run tests within a file in parallel:
|
||||
|
||||
```ts
|
||||
// Individual concurrent tests
|
||||
test.concurrent('test 1', async ({ expect }) => {
|
||||
expect(await fetch1()).toBe('result')
|
||||
})
|
||||
|
||||
test.concurrent('test 2', async ({ expect }) => {
|
||||
expect(await fetch2()).toBe('result')
|
||||
})
|
||||
|
||||
// All tests in suite concurrent
|
||||
describe.concurrent('parallel suite', () => {
|
||||
test('test 1', async ({ expect }) => {})
|
||||
test('test 2', async ({ expect }) => {})
|
||||
})
|
||||
```
|
||||
|
||||
**Important:** Use `{ expect }` from context for concurrent tests.
|
||||
|
||||
## Sequential in Concurrent Context
|
||||
|
||||
Force sequential execution:
|
||||
|
||||
```ts
|
||||
describe.concurrent('mostly parallel', () => {
|
||||
test('parallel 1', async () => {})
|
||||
test('parallel 2', async () => {})
|
||||
|
||||
test.sequential('must run alone 1', async () => {})
|
||||
test.sequential('must run alone 2', async () => {})
|
||||
})
|
||||
|
||||
// Or entire suite
|
||||
describe.sequential('sequential suite', () => {
|
||||
test('first', () => {})
|
||||
test('second', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
## Max Concurrency
|
||||
|
||||
Limit concurrent tests:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
maxConcurrency: 5, // Max concurrent tests per file
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Isolation
|
||||
|
||||
Each file runs in isolated environment by default:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
// Disable isolation for faster runs (less safe)
|
||||
isolate: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Sharding
|
||||
|
||||
Split tests across machines:
|
||||
|
||||
```bash
|
||||
# Machine 1
|
||||
vitest run --shard=1/3
|
||||
|
||||
# Machine 2
|
||||
vitest run --shard=2/3
|
||||
|
||||
# Machine 3
|
||||
vitest run --shard=3/3
|
||||
```
|
||||
|
||||
### CI Example (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob
|
||||
|
||||
merge:
|
||||
needs: test
|
||||
steps:
|
||||
- run: vitest --merge-reports --reporter=junit
|
||||
```
|
||||
|
||||
### Merge Reports
|
||||
|
||||
```bash
|
||||
# Each shard outputs blob
|
||||
vitest run --shard=1/3 --reporter=blob --coverage
|
||||
vitest run --shard=2/3 --reporter=blob --coverage
|
||||
|
||||
# Merge all blobs
|
||||
vitest --merge-reports --reporter=json --coverage
|
||||
```
|
||||
|
||||
## Test Sequence
|
||||
|
||||
Control test order:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
sequence: {
|
||||
// Run tests in random order
|
||||
shuffle: true,
|
||||
|
||||
// Seed for reproducible shuffle
|
||||
seed: 12345,
|
||||
|
||||
// Hook execution order
|
||||
hooks: 'stack', // 'stack', 'list', 'parallel'
|
||||
|
||||
// All tests concurrent by default
|
||||
concurrent: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Shuffle Tests
|
||||
|
||||
Randomize to catch hidden dependencies:
|
||||
|
||||
```ts
|
||||
// Via CLI
|
||||
vitest --sequence.shuffle
|
||||
|
||||
// Per suite
|
||||
describe.shuffle('random order', () => {
|
||||
test('test 1', () => {})
|
||||
test('test 2', () => {})
|
||||
test('test 3', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
## Pool Options
|
||||
|
||||
### Threads (Default)
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
maxThreads: 8,
|
||||
minThreads: 2,
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Forks
|
||||
|
||||
Better isolation, slower:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
forks: {
|
||||
maxForks: 4,
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### VM Threads
|
||||
|
||||
Full VM isolation per file:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
pool: 'vmThreads',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Bail on Failure
|
||||
|
||||
Stop after first failure:
|
||||
|
||||
```bash
|
||||
vitest --bail 1 # Stop after 1 failure
|
||||
vitest --bail # Stop on first failure (same as --bail 1)
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Files run in parallel by default
|
||||
- Use `.concurrent` for parallel tests within file
|
||||
- Always use context's `expect` in concurrent tests
|
||||
- Sharding splits tests across CI machines
|
||||
- Use `--merge-reports` to combine sharded results
|
||||
- Shuffle tests to find hidden dependencies
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/features.html#running-tests-concurrently
|
||||
- https://vitest.dev/guide/improving-performance.html
|
||||
-->
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
name: test-context-fixtures
|
||||
description: Test context, custom fixtures with test.extend
|
||||
---
|
||||
|
||||
# Test Context & Fixtures
|
||||
|
||||
## Built-in Context
|
||||
|
||||
Every test receives context as first argument:
|
||||
|
||||
```ts
|
||||
test('context', ({ task, expect, skip }) => {
|
||||
console.log(task.name) // Test name
|
||||
expect(1).toBe(1) // Context-bound expect
|
||||
skip() // Skip test dynamically
|
||||
})
|
||||
```
|
||||
|
||||
### Context Properties
|
||||
|
||||
- `task` - Test metadata (name, file, etc.)
|
||||
- `expect` - Expect bound to this test (important for concurrent tests)
|
||||
- `skip(condition?, message?)` - Skip the test
|
||||
- `onTestFinished(fn)` - Cleanup after test
|
||||
- `onTestFailed(fn)` - Run on failure only
|
||||
|
||||
## Custom Fixtures with test.extend
|
||||
|
||||
Create reusable test utilities:
|
||||
|
||||
```ts
|
||||
import { test as base } from 'vitest'
|
||||
|
||||
// Define fixture types
|
||||
interface Fixtures {
|
||||
db: Database
|
||||
user: User
|
||||
}
|
||||
|
||||
// Create extended test
|
||||
export const test = base.extend<Fixtures>({
|
||||
// Fixture with setup/teardown
|
||||
db: async ({}, use) => {
|
||||
const db = await createDatabase()
|
||||
await use(db) // Provide to test
|
||||
await db.close() // Cleanup
|
||||
},
|
||||
|
||||
// Fixture depending on another fixture
|
||||
user: async ({ db }, use) => {
|
||||
const user = await db.createUser({ name: 'Test' })
|
||||
await use(user)
|
||||
await db.deleteUser(user.id)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Using fixtures:
|
||||
|
||||
```ts
|
||||
test('query user', async ({ db, user }) => {
|
||||
const found = await db.findUser(user.id)
|
||||
expect(found).toEqual(user)
|
||||
})
|
||||
```
|
||||
|
||||
## Fixture Initialization
|
||||
|
||||
Fixtures only initialize when accessed:
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
expensive: async ({}, use) => {
|
||||
console.log('initializing') // Only runs if test uses it
|
||||
await use('value')
|
||||
},
|
||||
})
|
||||
|
||||
test('no fixture', () => {}) // expensive not called
|
||||
test('uses fixture', ({ expensive }) => {}) // expensive called
|
||||
```
|
||||
|
||||
## Auto Fixtures
|
||||
|
||||
Run fixture for every test:
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
setup: [
|
||||
async ({}, use) => {
|
||||
await globalSetup()
|
||||
await use()
|
||||
await globalTeardown()
|
||||
},
|
||||
{ auto: true } // Always run
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Scoped Fixtures
|
||||
|
||||
### File Scope
|
||||
|
||||
Initialize once per file:
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
connection: [
|
||||
async ({}, use) => {
|
||||
const conn = await connect()
|
||||
await use(conn)
|
||||
await conn.close()
|
||||
},
|
||||
{ scope: 'file' }
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Worker Scope
|
||||
|
||||
Initialize once per worker:
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
sharedResource: [
|
||||
async ({}, use) => {
|
||||
await use(globalResource)
|
||||
},
|
||||
{ scope: 'worker' }
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Injected Fixtures (from Config)
|
||||
|
||||
Override fixtures per project:
|
||||
|
||||
```ts
|
||||
// test file
|
||||
const test = base.extend({
|
||||
apiUrl: ['/default', { injected: true }],
|
||||
})
|
||||
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'prod',
|
||||
provide: { apiUrl: 'https://api.prod.com' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Scoped Values per Suite
|
||||
|
||||
Override fixture for specific suite:
|
||||
|
||||
```ts
|
||||
const test = base.extend({
|
||||
environment: 'development',
|
||||
})
|
||||
|
||||
describe('production tests', () => {
|
||||
test.scoped({ environment: 'production' })
|
||||
|
||||
test('uses production', ({ environment }) => {
|
||||
expect(environment).toBe('production')
|
||||
})
|
||||
})
|
||||
|
||||
test('uses default', ({ environment }) => {
|
||||
expect(environment).toBe('development')
|
||||
})
|
||||
```
|
||||
|
||||
## Extended Test Hooks
|
||||
|
||||
Type-aware hooks with fixtures:
|
||||
|
||||
```ts
|
||||
const test = base.extend<{ db: Database }>({
|
||||
db: async ({}, use) => {
|
||||
const db = await createDb()
|
||||
await use(db)
|
||||
await db.close()
|
||||
},
|
||||
})
|
||||
|
||||
// Hooks know about fixtures
|
||||
test.beforeEach(({ db }) => {
|
||||
db.seed()
|
||||
})
|
||||
|
||||
test.afterEach(({ db }) => {
|
||||
db.clear()
|
||||
})
|
||||
```
|
||||
|
||||
## Composing Fixtures
|
||||
|
||||
Extend from another extended test:
|
||||
|
||||
```ts
|
||||
// base-test.ts
|
||||
export const test = base.extend<{ db: Database }>({
|
||||
db: async ({}, use) => { /* ... */ },
|
||||
})
|
||||
|
||||
// admin-test.ts
|
||||
import { test as dbTest } from './base-test'
|
||||
|
||||
export const test = dbTest.extend<{ admin: User }>({
|
||||
admin: async ({ db }, use) => {
|
||||
const admin = await db.createAdmin()
|
||||
await use(admin)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `{ }` destructuring to access fixtures
|
||||
- Fixtures are lazy - only initialize when accessed
|
||||
- Return cleanup function from fixtures
|
||||
- Use `{ auto: true }` for setup fixtures
|
||||
- Use `{ scope: 'file' }` for expensive shared resources
|
||||
- Fixtures compose - extend from extended tests
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/test-context.html
|
||||
-->
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
name: code-coverage
|
||||
description: Code coverage with V8 or Istanbul providers
|
||||
---
|
||||
|
||||
# Code Coverage
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Run tests with coverage
|
||||
vitest run --coverage
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
// Provider: 'v8' (default, faster) or 'istanbul' (more compatible)
|
||||
provider: 'v8',
|
||||
|
||||
// Enable coverage
|
||||
enabled: true,
|
||||
|
||||
// Reporters
|
||||
reporter: ['text', 'json', 'html'],
|
||||
|
||||
// Files to include
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
|
||||
// Files to exclude
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.test.ts',
|
||||
],
|
||||
|
||||
// Report uncovered files
|
||||
all: true,
|
||||
|
||||
// Thresholds
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
### V8 (Default)
|
||||
|
||||
```bash
|
||||
npm i -D @vitest/coverage-v8
|
||||
```
|
||||
|
||||
- Faster, no pre-instrumentation
|
||||
- Uses V8's native coverage
|
||||
- Recommended for most projects
|
||||
|
||||
### Istanbul
|
||||
|
||||
```bash
|
||||
npm i -D @vitest/coverage-istanbul
|
||||
```
|
||||
|
||||
- Pre-instruments code
|
||||
- Works in any JS runtime
|
||||
- More overhead but widely compatible
|
||||
|
||||
## Reporters
|
||||
|
||||
```ts
|
||||
coverage: {
|
||||
reporter: [
|
||||
'text', // Terminal output
|
||||
'text-summary', // Summary only
|
||||
'json', // JSON file
|
||||
'html', // HTML report
|
||||
'lcov', // For CI tools
|
||||
'cobertura', // XML format
|
||||
],
|
||||
reportsDirectory: './coverage',
|
||||
}
|
||||
```
|
||||
|
||||
## Thresholds
|
||||
|
||||
Fail tests if coverage is below threshold:
|
||||
|
||||
```ts
|
||||
coverage: {
|
||||
thresholds: {
|
||||
// Global thresholds
|
||||
lines: 80,
|
||||
functions: 75,
|
||||
branches: 70,
|
||||
statements: 80,
|
||||
|
||||
// Per-file thresholds
|
||||
perFile: true,
|
||||
|
||||
// Auto-update thresholds (for gradual improvement)
|
||||
autoUpdate: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Ignoring Code
|
||||
|
||||
### V8
|
||||
|
||||
```ts
|
||||
/* v8 ignore next -- @preserve */
|
||||
function ignored() {
|
||||
return 'not covered'
|
||||
}
|
||||
|
||||
/* v8 ignore start -- @preserve */
|
||||
// All code here ignored
|
||||
/* v8 ignore stop -- @preserve */
|
||||
```
|
||||
|
||||
### Istanbul
|
||||
|
||||
```ts
|
||||
/* istanbul ignore next -- @preserve */
|
||||
function ignored() {}
|
||||
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (condition) {
|
||||
// ignored
|
||||
}
|
||||
```
|
||||
|
||||
Note: `@preserve` keeps comments through esbuild.
|
||||
|
||||
## Package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:watch": "vitest --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Vitest UI Coverage
|
||||
|
||||
Enable HTML coverage in Vitest UI:
|
||||
|
||||
```ts
|
||||
coverage: {
|
||||
enabled: true,
|
||||
reporter: ['text', 'html'],
|
||||
}
|
||||
```
|
||||
|
||||
Run with `vitest --ui` to view coverage visually.
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
```
|
||||
|
||||
## Coverage with Sharding
|
||||
|
||||
Merge coverage from sharded runs:
|
||||
|
||||
```bash
|
||||
vitest run --shard=1/3 --coverage --reporter=blob
|
||||
vitest run --shard=2/3 --coverage --reporter=blob
|
||||
vitest run --shard=3/3 --coverage --reporter=blob
|
||||
|
||||
vitest --merge-reports --coverage --reporter=json
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- V8 is faster, Istanbul is more compatible
|
||||
- Use `--coverage` flag or `coverage.enabled: true`
|
||||
- Include `all: true` to see uncovered files
|
||||
- Set thresholds to enforce minimum coverage
|
||||
- Use `@preserve` comment to keep ignore hints
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/coverage.html
|
||||
-->
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: test-filtering
|
||||
description: Filter tests by name, file patterns, and tags
|
||||
---
|
||||
|
||||
# Test Filtering
|
||||
|
||||
## CLI Filtering
|
||||
|
||||
### By File Path
|
||||
|
||||
```bash
|
||||
# Run files containing "user"
|
||||
vitest user
|
||||
|
||||
# Multiple patterns
|
||||
vitest user auth
|
||||
|
||||
# Specific file
|
||||
vitest src/user.test.ts
|
||||
|
||||
# By line number
|
||||
vitest src/user.test.ts:25
|
||||
```
|
||||
|
||||
### By Test Name
|
||||
|
||||
```bash
|
||||
# Tests matching pattern
|
||||
vitest -t "login"
|
||||
vitest --testNamePattern "should.*work"
|
||||
|
||||
# Regex patterns
|
||||
vitest -t "/user|auth/"
|
||||
```
|
||||
|
||||
## Changed Files
|
||||
|
||||
```bash
|
||||
# Uncommitted changes
|
||||
vitest --changed
|
||||
|
||||
# Since specific commit
|
||||
vitest --changed HEAD~1
|
||||
vitest --changed abc123
|
||||
|
||||
# Since branch
|
||||
vitest --changed origin/main
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
Run tests that import specific files:
|
||||
|
||||
```bash
|
||||
vitest related src/utils.ts src/api.ts --run
|
||||
```
|
||||
|
||||
Useful with lint-staged:
|
||||
|
||||
```js
|
||||
// .lintstagedrc.js
|
||||
export default {
|
||||
'*.{ts,tsx}': 'vitest related --run',
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Tests (.only)
|
||||
|
||||
```ts
|
||||
test.only('only this runs', () => {})
|
||||
|
||||
describe.only('only this suite', () => {
|
||||
test('runs', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
In CI, `.only` throws error unless configured:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
allowOnly: true, // Allow .only in CI
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Skip Tests
|
||||
|
||||
```ts
|
||||
test.skip('skipped', () => {})
|
||||
|
||||
// Conditional
|
||||
test.skipIf(process.env.CI)('not in CI', () => {})
|
||||
test.runIf(!process.env.CI)('local only', () => {})
|
||||
|
||||
// Dynamic skip
|
||||
test('dynamic', ({ skip }) => {
|
||||
skip(someCondition, 'reason')
|
||||
})
|
||||
```
|
||||
|
||||
## Tags
|
||||
|
||||
Filter by custom tags:
|
||||
|
||||
```ts
|
||||
test('database test', { tags: ['db'] }, () => {})
|
||||
test('slow test', { tags: ['slow', 'integration'] }, () => {})
|
||||
```
|
||||
|
||||
Run tagged tests:
|
||||
|
||||
```bash
|
||||
vitest --tags db
|
||||
vitest --tags "db,slow" # OR
|
||||
vitest --tags db --tags slow # OR
|
||||
```
|
||||
|
||||
Configure allowed tags:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
tags: ['db', 'slow', 'integration'],
|
||||
strictTags: true, // Fail on unknown tags
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Include/Exclude Patterns
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
// Test file patterns
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
|
||||
// Exclude patterns
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/e2e/**',
|
||||
'**/*.skip.test.ts',
|
||||
],
|
||||
|
||||
// Include source for in-source testing
|
||||
includeSource: ['src/**/*.ts'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Watch Mode Filtering
|
||||
|
||||
In watch mode, press:
|
||||
- `p` - Filter by filename pattern
|
||||
- `t` - Filter by test name pattern
|
||||
- `a` - Run all tests
|
||||
- `f` - Run only failed tests
|
||||
|
||||
## Projects Filtering
|
||||
|
||||
Run specific project:
|
||||
|
||||
```bash
|
||||
vitest --project unit
|
||||
vitest --project integration --project e2e
|
||||
```
|
||||
|
||||
## Environment-based Filtering
|
||||
|
||||
```ts
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const isCI = process.env.CI
|
||||
|
||||
describe.skipIf(isCI)('local only tests', () => {})
|
||||
describe.runIf(isDev)('dev tests', () => {})
|
||||
```
|
||||
|
||||
## Combining Filters
|
||||
|
||||
```bash
|
||||
# File pattern + test name + changed
|
||||
vitest user -t "login" --changed
|
||||
|
||||
# Related files + run mode
|
||||
vitest related src/auth.ts --run
|
||||
```
|
||||
|
||||
## List Tests Without Running
|
||||
|
||||
```bash
|
||||
vitest list # Show all test names
|
||||
vitest list -t "user" # Filter by name
|
||||
vitest list --filesOnly # Show only file paths
|
||||
vitest list --json # JSON output
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `-t` for test name pattern filtering
|
||||
- `--changed` runs only tests affected by changes
|
||||
- `--related` runs tests importing specific files
|
||||
- Tags provide semantic test grouping
|
||||
- Use `.only` for debugging, but configure CI to reject it
|
||||
- Watch mode has interactive filtering
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/filtering.html
|
||||
- https://vitest.dev/guide/cli.html
|
||||
-->
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
name: mocking
|
||||
description: Mock functions, modules, timers, and dates with vi utilities
|
||||
---
|
||||
|
||||
# Mocking
|
||||
|
||||
## Mock Functions
|
||||
|
||||
```ts
|
||||
import { expect, vi } from 'vitest'
|
||||
|
||||
// Create mock function
|
||||
const fn = vi.fn()
|
||||
fn('hello')
|
||||
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(fn).toHaveBeenCalledWith('hello')
|
||||
|
||||
// With implementation
|
||||
const add = vi.fn((a, b) => a + b)
|
||||
expect(add(1, 2)).toBe(3)
|
||||
|
||||
// Mock return values
|
||||
fn.mockReturnValue(42)
|
||||
fn.mockReturnValueOnce(1).mockReturnValueOnce(2)
|
||||
fn.mockResolvedValue({ data: true })
|
||||
fn.mockRejectedValue(new Error('fail'))
|
||||
|
||||
// Mock implementation
|
||||
fn.mockImplementation((x) => x * 2)
|
||||
fn.mockImplementationOnce(() => 'first call')
|
||||
```
|
||||
|
||||
## Spying on Objects
|
||||
|
||||
```ts
|
||||
const cart = {
|
||||
getTotal: () => 100,
|
||||
}
|
||||
|
||||
const spy = vi.spyOn(cart, 'getTotal')
|
||||
cart.getTotal()
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
|
||||
// Mock implementation
|
||||
spy.mockReturnValue(200)
|
||||
expect(cart.getTotal()).toBe(200)
|
||||
|
||||
// Restore original
|
||||
spy.mockRestore()
|
||||
```
|
||||
|
||||
## Module Mocking
|
||||
|
||||
```ts
|
||||
// vi.mock is hoisted to top of file
|
||||
vi.mock('./api', () => ({
|
||||
fetchUser: vi.fn(() => ({ id: 1, name: 'Mock' })),
|
||||
}))
|
||||
|
||||
import { fetchUser } from './api'
|
||||
|
||||
test('mocked module', () => {
|
||||
expect(fetchUser()).toEqual({ id: 1, name: 'Mock' })
|
||||
})
|
||||
```
|
||||
|
||||
### Partial Mock
|
||||
|
||||
```ts
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
specificFunction: vi.fn(),
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-mock with Spy
|
||||
|
||||
```ts
|
||||
// Keep implementation but spy on calls
|
||||
vi.mock('./calculator', { spy: true })
|
||||
|
||||
import { add } from './calculator'
|
||||
|
||||
test('spy on module', () => {
|
||||
const result = add(1, 2) // Real implementation
|
||||
expect(result).toBe(3)
|
||||
expect(add).toHaveBeenCalledWith(1, 2)
|
||||
})
|
||||
```
|
||||
|
||||
### Manual Mocks (__mocks__)
|
||||
|
||||
```
|
||||
src/
|
||||
__mocks__/
|
||||
axios.ts # Mocks 'axios'
|
||||
api/
|
||||
__mocks__/
|
||||
client.ts # Mocks './client'
|
||||
client.ts
|
||||
```
|
||||
|
||||
```ts
|
||||
// Just call vi.mock with no factory
|
||||
vi.mock('axios')
|
||||
vi.mock('./api/client')
|
||||
```
|
||||
|
||||
## Dynamic Mocking (vi.doMock)
|
||||
|
||||
Not hoisted - use for dynamic imports:
|
||||
|
||||
```ts
|
||||
test('dynamic mock', async () => {
|
||||
vi.doMock('./config', () => ({
|
||||
apiUrl: 'http://test.local',
|
||||
}))
|
||||
|
||||
const { apiUrl } = await import('./config')
|
||||
expect(apiUrl).toBe('http://test.local')
|
||||
|
||||
vi.doUnmock('./config')
|
||||
})
|
||||
```
|
||||
|
||||
## Mock Timers
|
||||
|
||||
```ts
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('timers', () => {
|
||||
const fn = vi.fn()
|
||||
setTimeout(fn, 1000)
|
||||
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(fn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Other timer methods
|
||||
vi.runAllTimers() // Run all pending timers
|
||||
vi.runOnlyPendingTimers() // Run only currently pending
|
||||
vi.advanceTimersToNextTimer() // Advance to next timer
|
||||
```
|
||||
|
||||
### Async Timer Methods
|
||||
|
||||
```ts
|
||||
test('async timers', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let resolved = false
|
||||
setTimeout(() => Promise.resolve().then(() => { resolved = true }), 100)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Mock Dates
|
||||
|
||||
```ts
|
||||
vi.setSystemTime(new Date('2024-01-01'))
|
||||
expect(new Date().getFullYear()).toBe(2024)
|
||||
|
||||
vi.useRealTimers() // Restore
|
||||
```
|
||||
|
||||
## Mock Globals
|
||||
|
||||
```ts
|
||||
vi.stubGlobal('fetch', vi.fn(() =>
|
||||
Promise.resolve({ json: () => ({ data: 'mock' }) })
|
||||
))
|
||||
|
||||
// Restore
|
||||
vi.unstubAllGlobals()
|
||||
```
|
||||
|
||||
## Mock Environment Variables
|
||||
|
||||
```ts
|
||||
vi.stubEnv('API_KEY', 'test-key')
|
||||
expect(import.meta.env.API_KEY).toBe('test-key')
|
||||
|
||||
// Restore
|
||||
vi.unstubAllEnvs()
|
||||
```
|
||||
|
||||
## Clearing Mocks
|
||||
|
||||
```ts
|
||||
const fn = vi.fn()
|
||||
fn()
|
||||
|
||||
fn.mockClear() // Clear call history
|
||||
fn.mockReset() // Clear history + implementation
|
||||
fn.mockRestore() // Restore original (for spies)
|
||||
|
||||
// Global
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
```
|
||||
|
||||
## Config Auto-Reset
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
clearMocks: true, // Clear before each test
|
||||
mockReset: true, // Reset before each test
|
||||
restoreMocks: true, // Restore after each test
|
||||
unstubEnvs: true, // Restore env vars
|
||||
unstubGlobals: true, // Restore globals
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Hoisted Variables for Mocks
|
||||
|
||||
```ts
|
||||
const mockFn = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./module', () => ({
|
||||
getData: mockFn,
|
||||
}))
|
||||
|
||||
import { getData } from './module'
|
||||
|
||||
test('hoisted mock', () => {
|
||||
mockFn.mockReturnValue('test')
|
||||
expect(getData()).toBe('test')
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- `vi.mock` is hoisted - called before imports
|
||||
- Use `vi.doMock` for dynamic, non-hoisted mocking
|
||||
- Always restore mocks to avoid test pollution
|
||||
- Use `{ spy: true }` to keep implementation but track calls
|
||||
- `vi.hoisted` lets you reference variables in mock factories
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/mocking.html
|
||||
- https://vitest.dev/api/vi.html
|
||||
-->
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
name: snapshot-testing
|
||||
description: Snapshot testing with file, inline, and file snapshots
|
||||
---
|
||||
|
||||
# Snapshot Testing
|
||||
|
||||
Snapshot tests capture output and compare against stored references.
|
||||
|
||||
## Basic Snapshot
|
||||
|
||||
```ts
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('snapshot', () => {
|
||||
const result = generateOutput()
|
||||
expect(result).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
First run creates `.snap` file:
|
||||
|
||||
```js
|
||||
// __snapshots__/test.spec.ts.snap
|
||||
exports['snapshot 1'] = `
|
||||
{
|
||||
"id": 1,
|
||||
"name": "test"
|
||||
}
|
||||
`
|
||||
```
|
||||
|
||||
## Inline Snapshots
|
||||
|
||||
Stored directly in test file:
|
||||
|
||||
```ts
|
||||
test('inline snapshot', () => {
|
||||
const data = { foo: 'bar' }
|
||||
expect(data).toMatchInlineSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
Vitest updates the test file:
|
||||
|
||||
```ts
|
||||
test('inline snapshot', () => {
|
||||
const data = { foo: 'bar' }
|
||||
expect(data).toMatchInlineSnapshot(`
|
||||
{
|
||||
"foo": "bar",
|
||||
}
|
||||
`)
|
||||
})
|
||||
```
|
||||
|
||||
## File Snapshots
|
||||
|
||||
Compare against explicit file:
|
||||
|
||||
```ts
|
||||
test('render html', async () => {
|
||||
const html = renderComponent()
|
||||
await expect(html).toMatchFileSnapshot('./expected/component.html')
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Hints
|
||||
|
||||
Add descriptive hints:
|
||||
|
||||
```ts
|
||||
test('multiple snapshots', () => {
|
||||
expect(header).toMatchSnapshot('header')
|
||||
expect(body).toMatchSnapshot('body content')
|
||||
expect(footer).toMatchSnapshot('footer')
|
||||
})
|
||||
```
|
||||
|
||||
## Object Shape Matching
|
||||
|
||||
Match partial structure:
|
||||
|
||||
```ts
|
||||
test('shape snapshot', () => {
|
||||
const data = {
|
||||
id: Math.random(),
|
||||
created: new Date(),
|
||||
name: 'test'
|
||||
}
|
||||
|
||||
expect(data).toMatchSnapshot({
|
||||
id: expect.any(Number),
|
||||
created: expect.any(Date),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Error Snapshots
|
||||
|
||||
```ts
|
||||
test('error message', () => {
|
||||
expect(() => {
|
||||
throw new Error('Something went wrong')
|
||||
}).toThrowErrorMatchingSnapshot()
|
||||
})
|
||||
|
||||
test('inline error', () => {
|
||||
expect(() => {
|
||||
throw new Error('Bad input')
|
||||
}).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`)
|
||||
})
|
||||
```
|
||||
|
||||
## Updating Snapshots
|
||||
|
||||
```bash
|
||||
# Update all snapshots
|
||||
vitest -u
|
||||
vitest --update
|
||||
|
||||
# In watch mode, press 'u' to update failed snapshots
|
||||
```
|
||||
|
||||
## Custom Serializers
|
||||
|
||||
Add custom snapshot formatting:
|
||||
|
||||
```ts
|
||||
expect.addSnapshotSerializer({
|
||||
test(val) {
|
||||
return val && typeof val.toJSON === 'function'
|
||||
},
|
||||
serialize(val, config, indentation, depth, refs, printer) {
|
||||
return printer(val.toJSON(), config, indentation, depth, refs)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or via config:
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
defineConfig({
|
||||
test: {
|
||||
snapshotSerializers: ['./my-serializer.ts'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Format Options
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
snapshotFormat: {
|
||||
printBasicPrototype: false, // Don't print Array/Object prototypes
|
||||
escapeString: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Concurrent Test Snapshots
|
||||
|
||||
Use context's expect:
|
||||
|
||||
```ts
|
||||
test.concurrent('concurrent 1', async ({ expect }) => {
|
||||
expect(await getData()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test.concurrent('concurrent 2', async ({ expect }) => {
|
||||
expect(await getOther()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot File Location
|
||||
|
||||
Default: `__snapshots__/<test-file>.snap`
|
||||
|
||||
Customize:
|
||||
|
||||
```ts
|
||||
defineConfig({
|
||||
test: {
|
||||
resolveSnapshotPath: (testPath, snapExtension) => {
|
||||
return testPath.replace('__tests__', '__snapshots__') + snapExtension
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Commit snapshot files to version control
|
||||
- Review snapshot changes in code review
|
||||
- Use hints for multiple snapshots in one test
|
||||
- Use `toMatchFileSnapshot` for large outputs (HTML, JSON)
|
||||
- Inline snapshots auto-update in test file
|
||||
- Use context's `expect` for concurrent tests
|
||||
|
||||
<!--
|
||||
Source references:
|
||||
- https://vitest.dev/guide/snapshot.html
|
||||
- https://vitest.dev/api/expect.html#tomatchsnapshot
|
||||
-->
|
||||
@@ -0,0 +1,42 @@
|
||||
# Supabase Monorepo
|
||||
|
||||
pnpm 10 + Turborepo monorepo. Requires Node >= 22.
|
||||
|
||||
## Structure
|
||||
|
||||
| Directory | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 19 |
|
||||
| `apps/docs` | Documentation site |
|
||||
| `apps/www` | Marketing website |
|
||||
| `packages/ui` | Shared UI components (shadcn/ui based) |
|
||||
| `packages/common` | Shared utilities and telemetry constants |
|
||||
| `e2e/studio` | Playwright E2E tests for Studio |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev:studio # run Studio dev server
|
||||
pnpm test:studio # run Studio unit tests (vitest)
|
||||
pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright)
|
||||
pnpm build --filter=studio # build Studio
|
||||
pnpm lint --filter=studio # lint Studio
|
||||
pnpm typecheck # typecheck all packages
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives.
|
||||
|
||||
**Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors.
|
||||
|
||||
**Language** — Use U.S. English everywhere.
|
||||
|
||||
**Studio shortcuts** — when adding or changing repeated Studio UI actions, use the shared shortcut registry and primitives in `apps/studio/state/shortcuts/` and `apps/studio/components/ui/Shortcut*.tsx`. Prefer registered, discoverable shortcuts over one-off keyboard listeners; keep `G then ...` chords for navigation.
|
||||
|
||||
## Studio
|
||||
|
||||
Pages router. Co-locate sub-components with parent. Avoid barrel re-export files.
|
||||
|
||||
See studio-\* skills for detailed studio conventions.
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# PostToolUse hook: format and lint files in apps/ or packages/ after Write or Edit.
|
||||
# Receives hook JSON on stdin from Claude Code.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Apps/packages that have ESLint configured.
|
||||
# Add new entries here when a new workspace gets ESLint set up.
|
||||
ESLINT_PACKAGES=(
|
||||
"apps/design-system:design-system"
|
||||
"apps/docs:docs"
|
||||
"apps/learn:learn"
|
||||
"apps/studio:studio"
|
||||
"apps/ui-library:ui-library"
|
||||
"apps/www:www"
|
||||
)
|
||||
|
||||
# Extract the file path from stdin JSON.
|
||||
# Falls back to .tool_response.filePath for Write tool compat.
|
||||
file_path=$(jq -r '.tool_input.file_path // .tool_response.filePath' 2>/dev/null)
|
||||
|
||||
if [[ -z "$file_path" || "$file_path" == "null" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR"
|
||||
|
||||
# Prettier and ESLint for supported file types
|
||||
case "$file_path" in
|
||||
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.scss|*.md|*.mdx|*.html|*.yaml|*.yml|*.sql)
|
||||
pnpm exec prettier --config prettier.config.mjs --write "$file_path"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ESLint only for JS/TS files in supported workspaces
|
||||
case "$file_path" in
|
||||
*.ts|*.tsx|*.js|*.jsx)
|
||||
for entry in "${ESLINT_PACKAGES[@]}"; do
|
||||
dir="${entry%%:*}"
|
||||
filter="${entry##*:}"
|
||||
if [[ "$file_path" == *"$dir/"* ]]; then
|
||||
pnpm --filter="$filter" exec eslint --fix "$file_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm install
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/scripts/install_pkgs.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/scripts/format_and_lint.sh",
|
||||
"statusMessage": "Formatting & linting..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: dev-toolbar-review
|
||||
description: Use when reviewing PRs that touch packages/dev-tools/, packages/common/posthog-client.ts,
|
||||
or packages/common/feature-flags.tsx. Covers environment guards, flag override cookies,
|
||||
telemetry event subscription, and SSE stream safety.
|
||||
---
|
||||
|
||||
# Dev Toolbar Review Guide
|
||||
|
||||
Review checklist for PRs touching the dev toolbar (`packages/dev-tools/`) and its
|
||||
integration points in `packages/common/`. The toolbar surfaces telemetry events and
|
||||
allows feature flag overrides during local development (expanding to staging/preview).
|
||||
|
||||
## When This Applies
|
||||
|
||||
PRs modifying any of these paths need growth eng review:
|
||||
|
||||
- `packages/dev-tools/**` (owned by `@supabase/growth-eng` in CODEOWNERS)
|
||||
- `packages/common/posthog-client.ts` (flag override reads, event subscription)
|
||||
- `packages/common/feature-flags.tsx` (flag override merge logic)
|
||||
- App-level mounting: `DevToolbarProvider`/`DevToolbar`/`DevToolbarTrigger` in `apps/studio/`, `apps/www/`, `apps/docs/`
|
||||
|
||||
Note: `posthog-client.ts` and `feature-flags.tsx` are NOT in CODEOWNERS for growth-eng,
|
||||
so PRs touching only those files won't auto-request review. Watch for these in the PR feed.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 1. Environment Guards
|
||||
|
||||
**Files:** `packages/dev-tools/index.ts`, `DevToolbar.tsx`, `DevToolbarTrigger.tsx`, `DevToolbarContext.tsx`
|
||||
|
||||
The toolbar uses two layers of protection:
|
||||
- **Build-time tree-shaking** in `index.ts`: `process.env.NODE_ENV !== 'development'` ternaries that replace components with noops/stubs so the implementation is eliminated from production bundles.
|
||||
- **Runtime guards** in components: `IS_LOCAL_DEV` checks — `DevToolbar` and `DevToolbarTrigger` return `null` to hide themselves, while `DevToolbarProvider` passes children through (`<>{children}</>`) to preserve the component tree.
|
||||
|
||||
**Check for:**
|
||||
- Guards being removed or broadened. The toolbar is expanding to staging and preview deploys but must remain invisible in production.
|
||||
- Tree-shaking ternaries in `index.ts` staying intact — these are the primary production safety mechanism.
|
||||
- New components or exports that bypass the existing guard pattern.
|
||||
|
||||
### 2. Flag Override Cookies
|
||||
|
||||
**Files:** `packages/dev-tools/DevToolbar.tsx`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx`
|
||||
|
||||
The toolbar writes two cookies that override feature flags locally:
|
||||
- `x-ph-flag-overrides` — PostHog flag overrides
|
||||
- `x-cc-flag-overrides` — ConfigCat flag overrides
|
||||
|
||||
These are read by:
|
||||
- `posthog-client.ts:getFeatureFlag()` — checks the PostHog override cookie before querying the SDK
|
||||
- `feature-flags.tsx` — merges both override cookies into the flag store during initialization
|
||||
|
||||
**Check for:**
|
||||
- Cookie name changes (must stay in sync across writer and all readers)
|
||||
- Changes to the merge/precedence logic in `feature-flags.tsx` (currently: `vercel-flag-overrides` first, then `x-cc-flag-overrides` takes precedence in local dev)
|
||||
- Override cookies being read outside the `IS_LOCAL_DEV` / `isLocalDev` guard — overrides must never affect production flag evaluation
|
||||
- Changes to `parseOverrideValue` or `valuesAreEqual` in `packages/dev-tools/utils.ts` that could cause type coercion bugs
|
||||
|
||||
### 3. Telemetry Event Subscription
|
||||
|
||||
**Files:** `packages/common/posthog-client.ts`, `packages/dev-tools/DevToolbarContext.tsx`
|
||||
|
||||
The toolbar subscribes to client-side PostHog events via `posthogClient.subscribeToEvents()`.
|
||||
The PostHog client calls `emitToDevListeners()` after `capturePageView`, `capturePageLeave`,
|
||||
and `identify`. Note: `captureExperimentExposure` calls `posthog.capture()` directly
|
||||
without emitting to dev listeners — experiment exposure events are invisible in the toolbar.
|
||||
|
||||
**Check for:**
|
||||
- Changes to `emitToDevListeners` or `subscribeToEvents` that could introduce side effects on the actual capture path (e.g., throwing errors, blocking, mutating event data)
|
||||
- The listener set (`devListeners`) being iterated synchronously in a way that could delay event dispatch
|
||||
- New PostHog client methods that capture events but don't call `emitToDevListeners` (gap in toolbar visibility)
|
||||
|
||||
### 4. SSE Server Telemetry Stream
|
||||
|
||||
**Files:** `packages/dev-tools/DevToolbarContext.tsx`
|
||||
|
||||
The toolbar connects to `${apiUrl}/telemetry/stream` via Server-Sent Events to display
|
||||
server-side telemetry. Uses exponential backoff on connection errors.
|
||||
|
||||
**Check for:**
|
||||
- Changes to the SSE endpoint URL or `session_id` cookie handling
|
||||
- Reconnection logic changes that could cause excessive retries or connection leaks
|
||||
- Note: the stream endpoint lives in the platform repo — cross-repo changes need coordinated review
|
||||
|
||||
### 5. App-Level Mounting
|
||||
|
||||
**Provider + toolbar panel** (`DevToolbarProvider`, `DevToolbar`):
|
||||
- `apps/studio/pages/_app.tsx`
|
||||
- `apps/www/pages/_app.tsx`, `apps/www/app/providers.tsx`
|
||||
- `apps/docs/features/app.providers.tsx`
|
||||
|
||||
**Trigger button** (`DevToolbarTrigger`) — rendered separately in nav/header components:
|
||||
- `apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx`
|
||||
- `apps/www/components/Nav/index.tsx`
|
||||
- `apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx`
|
||||
|
||||
**Check for:**
|
||||
- Provider being added or removed from an app
|
||||
- `apiUrl` prop changes (must point to the correct platform API)
|
||||
- Rendering order changes that could affect the toolbar's access to PostHog context
|
||||
|
||||
## What Doesn't Need Growth Review
|
||||
|
||||
Changes that are purely UI/UX within the toolbar panel itself — styling, layout, copy
|
||||
changes, drag behavior, popover positioning — don't need growth eng review unless they
|
||||
also touch the integration points above.
|
||||
@@ -0,0 +1,440 @@
|
||||
---
|
||||
name: safe-sql-execution
|
||||
description: Safely execute SQL queries against a user database without risking SQL injection or other security vulnerabilities.
|
||||
---
|
||||
|
||||
# Safe SQL execution
|
||||
|
||||
Supabase Studio executes SQL statements directly against the user's database.
|
||||
Because this is the authenticated user's own database, our security model is
|
||||
different from most frontend applications: a user should be able to execute any
|
||||
SQL statement, as long as it is proven that they themselves authored it. What
|
||||
we SHOULD NOT ALLOW is execution of SQL statements that can be influenced by an
|
||||
attacker, such as through URL parameters.
|
||||
|
||||
## Security model
|
||||
|
||||
The security model for SQL execution in Supabase Studio is based on the
|
||||
principle of "proven authorship". This means that a user should only be able to
|
||||
execute SQL statements that they have explicitly authored, and not statements
|
||||
that can be influenced by external input.
|
||||
|
||||
There are three classes of SQL fragments:
|
||||
|
||||
1. Hardcoded within the application code. These are safe to execute because
|
||||
they cannot be influenced by an attacker. They can be marked with the
|
||||
`safeSql` utility with `pg-meta`:
|
||||
|
||||
```ts
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
|
||||
const sql = safeSql`
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = 1
|
||||
`
|
||||
```
|
||||
|
||||
`safeSql` automatically creates a string of the branded type
|
||||
`SafeSqlFragment`. (See Provenance Tracking below.)
|
||||
|
||||
2. Third-party influenceable. These are SQL fragments that can be influenced
|
||||
by an attacker, such as through URL parameters or LLM output. These should
|
||||
be marked with the `untrustedSql` utility with `pg-meta`:
|
||||
|
||||
```ts
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
const unsafeQuery = searchParams.get('query')
|
||||
const querySql = untrustedSql(unsafeQuery)
|
||||
```
|
||||
|
||||
`untrustedSql` creates a string of the branded type `UntrustedSqlFragment`.
|
||||
(See Provenance Tracking below.)
|
||||
|
||||
3. User-authored. These are SQL fragments that are authored by the user
|
||||
themselves within the UI, for example in a text input field. Because the
|
||||
user is the author, these should be considered safe to execute.
|
||||
|
||||
However, there is a caveat, where third-party and user-authored code can
|
||||
mix, contaminating the user-authored code (for example, if an input is
|
||||
prefilled from an unsanitized URL parameter). Provenance tracking helps us
|
||||
track these cases.
|
||||
|
||||
For example, a safe input component could be implemented as follows by requiring that its placeholder and controlled value are of type `SafeSqlFragment`. In this case we can use its onChange to promote the user input to `SafeSqlFragment` type, because we know that the user is the author of the input. An implementation of this is in
|
||||
@apps/studio/components/ui/SafeSqlInput.tsx:
|
||||
|
||||
```ts
|
||||
import { rawSql, type SafeSqlFragment } from '@supabase/pg-meta'
|
||||
import type { ChangeEvent, ComponentProps } from 'react'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
type InputProps = ComponentProps<typeof Input>
|
||||
|
||||
export type SafeSqlInputProps = Omit<
|
||||
InputProps, 'placeholder' | 'value' | 'onChange'
|
||||
> & {
|
||||
placeholder?: SafeSqlFragment
|
||||
value: SafeSqlFragment
|
||||
onChange?:
|
||||
(event: ChangeEvent<HTMLInputElement>, value: SafeSqlFragment) => void
|
||||
}
|
||||
|
||||
export const SafeSqlInput = ({ onChange, ...props }: SafeSqlInputProps) => (
|
||||
<Input
|
||||
{...props}
|
||||
onChange={(event) => onChange?.(event, rawSql(event.target.value))}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
This is pretty much the ONLY VALID USE CASE of the rawSql export from
|
||||
pg-meta, and it should be used with caution.
|
||||
|
||||
## Provenance tracking
|
||||
|
||||
Branded types are used to track the provenance of SQL fragments. The types,
|
||||
exported from `pg-meta`, are:
|
||||
|
||||
- `SafeSqlFragment`: represents SQL fragments that are safe to execute, because
|
||||
they are either hardcoded in the application or authored by the user
|
||||
themselves.
|
||||
- `UntrustedSqlFragment`: represents SQL fragments that can be influenced by an
|
||||
attacker, such as through URL parameters or LLM output.
|
||||
|
||||
These are valid ways to generate a `SafeSqlFragment`:
|
||||
|
||||
- Using the `safeSql` utility from `pg-meta` to create hardcoded SQL fragments.
|
||||
- Using the sanitization utilities from `pg-meta` to sanitize untrusted input
|
||||
and promote it to a `SafeSqlFragment`:
|
||||
- `ident`
|
||||
- `literal`
|
||||
- `keyword`
|
||||
- Using the safe SQL manipulation utilities:
|
||||
- `joinSqlFragments`
|
||||
- `trimSafeSqlFragment`
|
||||
|
||||
`UntrustedSqlFragments` can be generated from raw strings using
|
||||
`untrustedSql()`.
|
||||
|
||||
There is also a union type, `DisplayableSqlFragment`, which represents SQL fragments that can be safely displayed in the UI, but not necessarily executed. This includes both `SafeSqlFragment` and `UntrustedSqlFragment`.
|
||||
|
||||
## Security of SQL round-tripped from the user's database
|
||||
|
||||
SQL derived directly from catalog tables (e.g., function definitions, RLS
|
||||
expressions, etc.) is considered safe, and it is promoted AT THE POINT OF
|
||||
BEING QUERIED from the database. In most cases, this is in an
|
||||
apps/studio/data/\*_/_.ts file, in the utility function that makes the API or
|
||||
database fetch.
|
||||
|
||||
A critical exception to the safety of SQL round-tripped from the database is
|
||||
user snippets. These must NEVER BE CONSIDERED SAFE because they are both (a)
|
||||
externally influenceable and (b) auto-saved. The snippet type uses the
|
||||
`unchecked_sql` property, which is an `UntrustedSqlFragment`, to enforce this.
|
||||
|
||||
## Promoting SQL fragments to `SafeSqlFragment` type
|
||||
|
||||
Given an insecure string or `UntrustedSqlFragment`, how do we promote it safely
|
||||
to a `SafeSqlFragment`?
|
||||
|
||||
### Sanitization utilities
|
||||
|
||||
This is the preferred method when the input is sanitizable, e.g., it is a
|
||||
relation name, a column name, will be compared as a literal, etc.
|
||||
|
||||
The pg-meta library provides the following sanitization utilities that can be
|
||||
used to safely promote untrusted input to `SafeSqlFragment`:
|
||||
|
||||
- `ident`: for sanitizing identifiers such as table names or column names.
|
||||
- `literal`: for sanitizing literal values that will be used in SQL statements.
|
||||
- `keyword`: for sanitizing SQL keywords.
|
||||
|
||||
### `acceptUntrustedSql`
|
||||
|
||||
Some untrusted SQL fragments cannot be sanitized with the above utilities. For
|
||||
example, the `USING` expression in the RLS policy editor is an arbitrary SQL
|
||||
expression.
|
||||
|
||||
In these cases, we can promote the SQL fragment _upon explicit user action_.
|
||||
User action indicates that the user has seen the SQL and is OK with running it.
|
||||
For example, an explicit user action could be clicking a "Run" button.
|
||||
|
||||
The promotion happens with the `acceptUntrustedSql` utility from `pg-meta`,
|
||||
which takes an `UntrustedSqlFragment` and returns a `SafeSqlFragment`.
|
||||
|
||||
This utility MUST ONLY BE USED IN event handlers. It should NEVER be used in
|
||||
a useQuery, direct in the render body of a component, in a useEffect, or
|
||||
anywhere it could auto-run without explicit user action.
|
||||
|
||||
This is safe:
|
||||
|
||||
```ts
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function SafeComponent() {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
const handleRun = () => {
|
||||
// ✅ GOOD: Safe because it is in an event handler which requires a user
|
||||
// click
|
||||
execute({ sql: acceptUntrustedSql(/* sql */) })
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleRun}>Run</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This is unsafe:
|
||||
|
||||
```ts
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function UnsafeComponent() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['execute-sql', sql],
|
||||
queryFn: () => {
|
||||
// 🛑 BAD: Unsafe because it is in a query which could auto-run without
|
||||
// explicit user action
|
||||
return execute({ sql: acceptUntrustedSql(/* sql */) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Type guarantees
|
||||
|
||||
SQL run against the user's Postgres database runs through the `executeSql`
|
||||
function, which only takes arguments of type `SafeSqlFragment` for the SQL
|
||||
parameter. Raw strings or `UntrustedSqlFragment`s will error at compile time.
|
||||
|
||||
## Examples
|
||||
|
||||
### Hard-coded SQL
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: Automatically safe with `safeSql` utility
|
||||
const selectStatement = safeSql`select 1`
|
||||
```
|
||||
|
||||
### SQL with sanitizable interpolations
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: `pg-meta` utilities sanitize the input
|
||||
const tableName = ident(userInputTableName)
|
||||
const searchString = literal(userInputSearchString)
|
||||
const sqlStatement = safeSql`
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE search_column = ${searchString}
|
||||
`
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Passing raw strings will type error
|
||||
const tableName = 'my_table'
|
||||
const sqlStatement = safeSql`
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
`
|
||||
```
|
||||
|
||||
### Non-sanitizable SQL from a user input
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: SafeSqlInput only allows a value that is a SafeSqlFragment
|
||||
import { SafeSqlInput } from '@apps/studio/components/ui/SafeSqlInput'
|
||||
|
||||
function MyComponent() {
|
||||
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
|
||||
|
||||
return (
|
||||
<SafeSqlInput
|
||||
placeholder={safeSql`Enter your SQL query here...`}
|
||||
value={sql}
|
||||
onChange={(event, value) => setSql(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: This input mixes SafeSqlFragments and unsafe strings
|
||||
|
||||
function MyBadComponent() {
|
||||
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
|
||||
|
||||
return (
|
||||
<Input
|
||||
// 🛑 BAD: This is unsafe because the placeholder is a raw string
|
||||
placeholder="Enter your SQL query here..."
|
||||
value={sql}
|
||||
onChange={(event) => setSql(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Round-tripping SQL from the database (NOT snippet content)
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
|
||||
// of fetching
|
||||
|
||||
// data/function-definitions.ts
|
||||
function markFunctionDefinitionSafe(
|
||||
functionDefinition: FunctionDefinition
|
||||
): SafeFunctionDefinition {
|
||||
return {
|
||||
...functionDefinition,
|
||||
definition: functionDefinition.definition as SafeSqlFragment,
|
||||
}
|
||||
}
|
||||
|
||||
// data/function-definitions.ts
|
||||
function getFunctionDefinitions() {
|
||||
return GET(`/function-definitions`).then((functionDefinitions) =>
|
||||
functionDefinitions.map(markFunctionDefinitionSafe)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Strings are promoted to SafeSqlFragment in a utility function, where
|
||||
// it is impossible to easily determine the safety of the input
|
||||
|
||||
// utils.ts
|
||||
function markFunctionDefinitionSafe(
|
||||
functionDefinition: FunctionDefinition
|
||||
): SafeFunctionDefinition {
|
||||
return {
|
||||
...functionDefinition,
|
||||
definition: functionDefinition.definition as SafeSqlFragment,
|
||||
}
|
||||
}
|
||||
|
||||
// Component.ts
|
||||
function MyComponent() {
|
||||
const { data: functionDefinitions } = useFunctionDefinitions()
|
||||
const safeFunctionDefinitions = functionDefinitions.map(markFunctionDefinitionSafe)
|
||||
}
|
||||
```
|
||||
|
||||
### Snippet content is ALWAYS UNSAFE
|
||||
|
||||
Snippets are auto-persisted to the database and can be created or modified
|
||||
through externally influenceable channels (e.g., prefilled from URL params).
|
||||
The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this
|
||||
— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an
|
||||
event handler that requires explicit user action.
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Snippet content is executed automatically via useQuery, with no
|
||||
// explicit user action confirming that the user has reviewed the SQL.
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function UnsafeSnippetPreview({ snippet }: { snippet: Snippet }) {
|
||||
const { data } = useExecuteSqlQuery({
|
||||
sql: acceptUntrustedSql(snippet.content.unchecked_sql),
|
||||
})
|
||||
|
||||
return <Results data={data} />
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Casting bypasses the type system entirely. The snippet's
|
||||
// `unchecked_sql` is `UntrustedSqlFragment` for a reason — never cast it.
|
||||
function UnsafeSnippetRunner({ snippet }: { snippet: Snippet }) {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
useEffect(() => {
|
||||
execute({ sql: snippet.content.unchecked_sql as SafeSqlFragment })
|
||||
}, [snippet])
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: Snippet content is only promoted to SafeSqlFragment inside an event
|
||||
// handler, after the user clicks Run. The user has seen the SQL in the editor
|
||||
// and explicitly chosen to execute it.
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function SnippetRunner({ snippet }: { snippet: Snippet }) {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
const handleRun = () => {
|
||||
execute({ sql: acceptUntrustedSql(snippet.content.unchecked_sql) })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SnippetEditor snippet={snippet} />
|
||||
<button onClick={handleRun}>Run</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Analytics SQL (BigQuery / ClickHouse)
|
||||
|
||||
The same security model applies to analytics queries, which target BigQuery
|
||||
or ClickHouse via the
|
||||
`/platform/projects/{ref}/analytics/endpoints/logs.all{,.otel}` endpoints.
|
||||
Filter keys and values from URL parameters and UI inputs are spliced into SQL
|
||||
that runs against the project's logs, so the same injection risk exists.
|
||||
|
||||
The brand and helpers live in `apps/studio/data/logs/safe-analytics-sql.ts`,
|
||||
intentionally **disjoint** from the pg-meta `SafeSqlFragment` brand:
|
||||
|
||||
- `SafeLogSqlFragment` — branded type for analytics SQL.
|
||||
- `safeSql` — template tag that only accepts `SafeLogSqlFragment`
|
||||
interpolations.
|
||||
- `analyticsLiteral(value)` — sanitizes string/number/boolean literals.
|
||||
- `quotedIdent(name)` — validates and backtick-quotes dotted identifiers.
|
||||
- `keyword(value, allowed)` — validates against an allow-list of operators.
|
||||
- `joinSqlFragments(fragments, separator)` — composes already-branded
|
||||
fragments.
|
||||
|
||||
The brands are kept separate because escape semantics differ — Postgres-safe
|
||||
`E'…'` strings, `::jsonb` casts, and double-quoted identifiers are unsafe for
|
||||
BigQuery and/or ClickHouse, and vice versa. Crossing the brands would silently
|
||||
emit unsafe SQL.
|
||||
|
||||
The wire-boundary wrapper is `executeAnalyticsSql` in
|
||||
`apps/studio/data/logs/execute-analytics-sql.ts`, analogous to pg-meta's
|
||||
`executeSql`. It accepts only `SafeLogSqlFragment` for its `sql` parameter, so
|
||||
raw strings are rejected at compile time. A grep-based vitest
|
||||
(`apps/studio/tests/unit/lints/analytics-sql-boundary.test.ts`) prevents
|
||||
regressions by failing the build if any file outside
|
||||
`execute-analytics-sql.ts` calls `post()` or `get()` directly against
|
||||
`logs.all` or `logs.all.otel`.
|
||||
|
||||
```ts
|
||||
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
|
||||
import { analyticsLiteral, quotedIdent, safeSql } from '@/data/logs/safe-analytics-sql'
|
||||
|
||||
// ✅ GOOD: every interpolation is sanitized.
|
||||
const sql = safeSql`
|
||||
SELECT timestamp, event_message
|
||||
FROM ${quotedIdent(table)}
|
||||
WHERE id = ${analyticsLiteral(id)}
|
||||
`
|
||||
|
||||
await executeAnalyticsSql({
|
||||
projectRef,
|
||||
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
|
||||
sql,
|
||||
iso_timestamp_start,
|
||||
iso_timestamp_end,
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: raw string interpolation. This fails to type-check at the
|
||||
// executeAnalyticsSql boundary because the result is `string`, not
|
||||
// `SafeLogSqlFragment`.
|
||||
const sql = `SELECT * FROM ${table} WHERE id = '${id}'`
|
||||
await executeAnalyticsSql({ projectRef, endpoint, sql, ... })
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: studio-best-practices
|
||||
description: React and TypeScript best practices for Supabase Studio. Use when writing
|
||||
or reviewing Studio components — covers boolean naming, component structure, loading/error
|
||||
states, state management, custom hooks, event handlers, conditional rendering,
|
||||
performance, and TypeScript conventions.
|
||||
---
|
||||
|
||||
# Studio Best Practices
|
||||
|
||||
Applies to `apps/studio/**/*.{ts,tsx}`.
|
||||
|
||||
## Boolean Naming
|
||||
|
||||
Use descriptive prefixes — derive from existing state rather than storing separately:
|
||||
|
||||
- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord`
|
||||
- `has` — possession: `hasPermission`, `hasData`
|
||||
- `can` — capability: `canUpdateColumns`, `canDelete`
|
||||
- `should` — conditional behavior: `shouldFetch`, `shouldRender`
|
||||
|
||||
Extract complex conditions into named variables:
|
||||
|
||||
```tsx
|
||||
// ❌ inline multi-condition
|
||||
{
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && <Button />
|
||||
}
|
||||
|
||||
// ✅ named variable
|
||||
const canShowAddButton =
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading
|
||||
{
|
||||
canShowAddButton && <Button />
|
||||
}
|
||||
```
|
||||
|
||||
Derive booleans — don't store them:
|
||||
|
||||
```tsx
|
||||
// ❌ stored derived state
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsFormValid(name.length > 0 && email.includes('@'))
|
||||
}, [name, email])
|
||||
|
||||
// ✅ derived
|
||||
const isFormValid = name.length > 0 && email.includes('@')
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
See `vercel-composition-patterns` skill for compound component and composition patterns.
|
||||
|
||||
Keep components under 200–300 lines. Split when you see:
|
||||
|
||||
- Multiple distinct UI sections
|
||||
- Complex conditional rendering
|
||||
- Multiple unrelated `useState` calls
|
||||
- Hard to understand at a glance
|
||||
|
||||
Co-locate sub-components in the same directory as the parent. Avoid barrel re-export files.
|
||||
|
||||
Extract repeated JSX patterns into small components.
|
||||
|
||||
## Data Fetching
|
||||
|
||||
All data fetching uses TanStack Query (React Query). See `studio-queries` skill for query/mutation patterns and `studio-error-handling` skill for error display conventions.
|
||||
|
||||
### Loading / Error / Success Pattern
|
||||
|
||||
Top level:
|
||||
|
||||
```tsx
|
||||
const { data, error, isLoading, isError, isSuccess } = useQuery(...)
|
||||
|
||||
if (isLoading) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to load data" />
|
||||
if (isSuccess && data.length === 0) return <EmptyState />
|
||||
return <DataDisplay data={data} />
|
||||
```
|
||||
|
||||
Use early returns — avoid deeply nested conditionals.
|
||||
|
||||
Inline:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
{isLoading && <InlineLoader />}
|
||||
{isError && <InlineError error={error} />}
|
||||
{isSuccess && data.length === 0 && <EmptyState />}
|
||||
{isSuccess && data.length > 0 && <DataDisplay data={data} />}
|
||||
</div>
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Keep state as local as possible; lift only when needed.
|
||||
|
||||
Group related form state with `react-hook-form` rather than multiple `useState` calls. See `studio-ui-patterns` skill for form layout and component conventions.
|
||||
|
||||
```tsx
|
||||
// ❌ multiple related useState
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
// ✅ grouped with react-hook-form
|
||||
const form = useForm<FormValues>({ defaultValues: { name: '', email: '' } })
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
Extract complex or reusable logic into hooks. Return objects, not arrays:
|
||||
|
||||
```tsx
|
||||
// ❌ array return (hard to extend)
|
||||
return [value, toggle]
|
||||
|
||||
// ✅ object return
|
||||
return { value, toggle, setTrue, setFalse }
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
- Prop callbacks: `on` prefix (`onClose`, `onSave`)
|
||||
- Internal handlers: `handle` prefix (`handleSubmit`, `handleCancel`)
|
||||
|
||||
Use `useCallback` for handlers passed to memoized children; avoid unnecessary inline arrow functions.
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```tsx
|
||||
// Simple show/hide
|
||||
<>{isVisible && <Component />}</>
|
||||
|
||||
// Binary choice
|
||||
<>{isLoading ? <Spinner /> : <Content />}</>
|
||||
|
||||
// Multiple conditions — use early returns, not nested ternaries
|
||||
if (isLoading) return <Spinner />
|
||||
if (isError) return <Error />
|
||||
return <Content />
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
`useMemo` for genuinely expensive computations (measured, not assumed). Don't wrap everything — only optimize when you have a measured problem or are passing values to memoized children.
|
||||
|
||||
## TypeScript
|
||||
|
||||
Define prop interfaces explicitly. Use discriminated unions for complex state:
|
||||
|
||||
```tsx
|
||||
type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error }
|
||||
```
|
||||
|
||||
Avoid `as any` / `as Type` casts. Validate at boundaries with zod:
|
||||
|
||||
```tsx
|
||||
// ❌ type cast
|
||||
const user = apiResponse as User
|
||||
|
||||
// ✅ zod parse
|
||||
const user = userSchema.parse(apiResponse)
|
||||
// or safe:
|
||||
const result = userSchema.safeParse(apiResponse)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Extract logic into `.utils.ts` pure functions and test exhaustively. See the `studio-testing` skill for the full testing strategy and decision tree.
|
||||
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: studio-e2e-tests
|
||||
description: Write and run Playwright E2E tests for Supabase Studio. Use when asked
|
||||
to run e2e tests, write new E2E tests, or debug flaky tests. Covers running commands,
|
||||
avoiding race conditions, waiting strategies, selectors, helper functions, and CI
|
||||
vs local differences.
|
||||
---
|
||||
|
||||
# E2E Studio Tests
|
||||
|
||||
Run Playwright end-to-end tests for the Studio application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Tests must be run from the `e2e/studio` directory:
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e
|
||||
```
|
||||
|
||||
### Run specific file
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- features/cron-jobs.spec.ts
|
||||
```
|
||||
|
||||
### Run with grep filter
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --grep "test name pattern"
|
||||
```
|
||||
|
||||
### UI mode for debugging
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --ui
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
- Tests auto-start Supabase local containers via web server config
|
||||
- Self-hosted mode (`IS_PLATFORM=false`) runs tests in parallel (3 workers)
|
||||
- No manual setup needed for self-hosted tests
|
||||
|
||||
## Test File Structure
|
||||
|
||||
- Tests are in `e2e/studio/features/*.spec.ts`
|
||||
- Use custom test utility: `import { test } from '../utils/test.js'`
|
||||
- Test fixtures provide `page`, `ref`, and other helpers
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Wait for elements with generous timeouts:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 })
|
||||
```
|
||||
|
||||
Add messages to expects for debugging:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 }, 'Element should be visible after page load')
|
||||
```
|
||||
|
||||
Use serial mode for tests sharing database state:
|
||||
|
||||
```typescript
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
```
|
||||
|
||||
## Writing Robust Selectors
|
||||
|
||||
### Selector priority (best to worst)
|
||||
|
||||
1. **`getByRole` with accessible name** - Most robust, tests accessibility
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
2. **`getByTestId`** - Stable, explicit test hooks
|
||||
|
||||
```typescript
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
```
|
||||
|
||||
3. **`getByText` with exact match** - Good for unique text
|
||||
|
||||
```typescript
|
||||
page.getByText('Data API Access', { exact: true })
|
||||
```
|
||||
|
||||
4. **`locator` with CSS** - Use sparingly, more fragile
|
||||
```typescript
|
||||
page.locator('[data-state="open"]')
|
||||
```
|
||||
|
||||
### Patterns to avoid
|
||||
|
||||
- **XPath selectors** - Fragile to DOM changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
locator('xpath=ancestor::div[contains(@class, "space-y")]')
|
||||
```
|
||||
|
||||
- **Parent traversal with `locator('..')`** - Breaks when structure changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
element.locator('..').getByRole('button')
|
||||
```
|
||||
|
||||
- **Broad `filter({ hasText })` on generic elements** - May match multiple elements
|
||||
```typescript
|
||||
// BAD - popover may have more than one combobox
|
||||
// Could consider scoping down the container or filtering the combobox more specifically
|
||||
popover.getByRole('combobox')
|
||||
```
|
||||
|
||||
### Add accessible labels to components
|
||||
|
||||
When a component lacks a good accessible name, add one in the source code:
|
||||
|
||||
```tsx
|
||||
// In the React component
|
||||
<Button aria-label="Configure API privileges">
|
||||
<Settings />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Then use it in tests:
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
### Narrowing search scope
|
||||
|
||||
Scope selectors to specific containers to avoid matching wrong elements:
|
||||
|
||||
```typescript
|
||||
// Good - scoped to side panel
|
||||
const sidePanel = page.getByTestId('table-editor-side-panel')
|
||||
const toggle = sidePanel.getByRole('switch')
|
||||
|
||||
// Good - find unique element, then scope from there
|
||||
const popover = page.locator('[data-radix-popper-content-wrapper]')
|
||||
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
|
||||
```
|
||||
|
||||
## Avoiding Race Conditions
|
||||
|
||||
**Set up API waiters BEFORE triggering actions.** This is the most common source of flaky tests.
|
||||
|
||||
```ts
|
||||
// ❌ Race condition — response may complete before waiter is set up
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
|
||||
// ✅ Waiter is ready before the action
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await apiPromise
|
||||
```
|
||||
|
||||
Same rule applies before navigation:
|
||||
|
||||
```ts
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
```
|
||||
|
||||
When an action triggers multiple API calls, wait for all of them:
|
||||
|
||||
```ts
|
||||
const createTablePromise = waitForApiResponseWithTimeout(page, (r) =>
|
||||
r.url().includes('query?key=table-create')
|
||||
)
|
||||
const tablesPromise = waitForApiResponseWithTimeout(page, (r) =>
|
||||
r.url().includes('tables?include_columns=true')
|
||||
)
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await Promise.all([createTablePromise, tablesPromise])
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
Playwright auto-waits for elements to be actionable — prefer this over manual timeouts.
|
||||
|
||||
Use `expect.poll` for dynamic state changes:
|
||||
|
||||
```ts
|
||||
await expect.poll(async () => await page.getByLabel(`View ${tableName}`).count()).toBe(0)
|
||||
```
|
||||
|
||||
Use `waitForSelector` with state for element lifecycle:
|
||||
|
||||
```ts
|
||||
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
|
||||
```
|
||||
|
||||
Avoid `networkidle` — use specific API waits instead:
|
||||
|
||||
```ts
|
||||
// ❌ Unreliable and slow
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// ✅ Specific API response
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
|
||||
```
|
||||
|
||||
Timeouts are acceptable only for client-side debounces:
|
||||
|
||||
```ts
|
||||
await page.getByRole('textbox').fill('search term')
|
||||
await page.waitForTimeout(300) // allow debounce
|
||||
```
|
||||
|
||||
## Avoiding `waitForTimeout`
|
||||
|
||||
Never use `waitForTimeout` - always wait for something specific:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// GOOD - wait for UI element
|
||||
await expect(page.getByText('Success')).toBeVisible()
|
||||
|
||||
// GOOD - wait for API response
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await saveButton.click()
|
||||
await apiPromise
|
||||
|
||||
// GOOD - wait for toast indicating operation complete
|
||||
await expect(page.getByText('Table created successfully')).toBeVisible({ timeout: 15000 })
|
||||
```
|
||||
|
||||
## Avoiding `force: true` on clicks
|
||||
|
||||
Instead of forcing clicks on hidden elements, make them visible first:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
|
||||
// GOOD - hover to reveal, then click
|
||||
await tableRow.hover()
|
||||
await expect(menuButton).toBeVisible()
|
||||
await menuButton.click()
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Always import from the custom test utility:
|
||||
|
||||
```ts
|
||||
import { test } from '../utils/test.js'
|
||||
```
|
||||
|
||||
Use `withFileOnceSetup` for expensive setup that should run once per file:
|
||||
|
||||
```ts
|
||||
test.beforeAll(async ({ browser, ref }) => {
|
||||
await withFileOnceSetup(import.meta.url, async () => {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await deleteTestTables(page, ref)
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await releaseFileOnceCleanup(import.meta.url)
|
||||
})
|
||||
```
|
||||
|
||||
Dismiss toasts before interacting — they can overlay buttons:
|
||||
|
||||
```ts
|
||||
const dismissToastsIfAny = async (page: Page) => {
|
||||
const closeButtons = page.getByRole('button', { name: 'Close toast' })
|
||||
const count = await closeButtons.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await closeButtons.nth(i).click()
|
||||
}
|
||||
}
|
||||
|
||||
await dismissToastsIfAny(page)
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
Always include descriptive messages for easier debugging:
|
||||
|
||||
```ts
|
||||
// ❌ No context on failure
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
|
||||
// ✅ Clear message on failure
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save' }),
|
||||
'Save button should be visible after form is filled'
|
||||
).toBeVisible()
|
||||
```
|
||||
|
||||
Use explicit timeouts for slow operations:
|
||||
|
||||
```ts
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should be visible after table creation'
|
||||
).toBeVisible({ timeout: 50000 })
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Extract reusable operations into domain helpers (e.g. `e2e/studio/utils/storage-helpers.ts`).
|
||||
Use the existing wait utilities:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createApiResponseWaiter,
|
||||
waitForApiResponse,
|
||||
waitForGridDataToLoad,
|
||||
waitForTableToLoad,
|
||||
} from '../utils/wait-for-response.js'
|
||||
```
|
||||
|
||||
Use `expectClipboardValue` instead of manual clipboard reads with hardcoded timeouts:
|
||||
|
||||
```ts
|
||||
// ❌ Brittle
|
||||
await page.evaluate(() => navigator.clipboard.readText())
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// ✅ Uses Playwright auto-retries
|
||||
await expectClipboardValue({ page, value: 'expectedValue' })
|
||||
```
|
||||
|
||||
## API Mocking
|
||||
|
||||
```ts
|
||||
await page.route('*/**/logs.all*', async (route) => {
|
||||
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
|
||||
})
|
||||
```
|
||||
|
||||
Use soft waits for optional API calls:
|
||||
|
||||
```ts
|
||||
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
|
||||
soft: true,
|
||||
fallbackWaitMs: 1000,
|
||||
})
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
Clean up test data in `beforeAll`/`beforeEach`. Check before deleting to handle existing state gracefully:
|
||||
|
||||
```ts
|
||||
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
||||
if ((await bucketRow.count()) === 0) return
|
||||
// proceed with deletion
|
||||
```
|
||||
|
||||
Reset local storage after tests that modify it:
|
||||
|
||||
```ts
|
||||
import { resetLocalStorage } from '../utils/reset-local-storage.js'
|
||||
|
||||
await resetLocalStorage(page, ref)
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View trace
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-trace <path-to-trace.zip>
|
||||
```
|
||||
|
||||
### View HTML report
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
### Error context
|
||||
|
||||
Error context files are saved in the `test-results/` directory.
|
||||
|
||||
### Playwright MCP tools
|
||||
|
||||
Use Playwright MCP tools to inspect UI when debugging locally.
|
||||
|
||||
## CI vs Local Development
|
||||
|
||||
The key difference is **cold start vs warm state**:
|
||||
|
||||
### CI (cold start)
|
||||
|
||||
Tests run from a blank database slate. Each test run resets the database and starts fresh containers. Extensions like pg_cron are NOT enabled by default.
|
||||
|
||||
### Local dev with `pnpm dev:studio-local`
|
||||
|
||||
When debugging with a running dev server, the database may already have state from previous runs (extensions enabled, test data present).
|
||||
|
||||
## Handling Cold Start Bugs
|
||||
|
||||
Tests that work locally but fail in CI often have assumptions about existing state.
|
||||
|
||||
### Common issues
|
||||
|
||||
1. Extension not enabled (must enable in test setup)
|
||||
2. Race conditions when parallel tests try to modify shared state (use `test.describe.configure({ mode: 'serial' })`)
|
||||
3. Locators matching wrong elements because the page structure differs when state isn't set up
|
||||
|
||||
### Reproducing CI behavior locally
|
||||
|
||||
The test framework automatically resets the database when running `pnpm run e2e`. This matches CI behavior.
|
||||
|
||||
If using `pnpm dev:studio-local` for Playwright MCP debugging, remember the state differs from CI.
|
||||
|
||||
## Debugging Workflow for CI Failures
|
||||
|
||||
1. First, run the test locally with `pnpm run e2e -- features/<file>.spec.ts` (cold start)
|
||||
2. Check error context in `test-results/` directory
|
||||
3. If you need to inspect UI state, start `pnpm dev:studio-local` and use Playwright MCP tools
|
||||
4. Remember: what you see in the dev server may have state that doesn't exist in CI
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: studio-error-handling
|
||||
description: Error display and troubleshooting pattern for Supabase Studio. Use when
|
||||
rendering API errors in the UI, adding inline troubleshooting steps for a new
|
||||
error type, or wiring up the AI assistant debug button from an error state.
|
||||
---
|
||||
|
||||
# Studio Error Handling Pattern
|
||||
|
||||
Full docs and code examples: `apps/studio/components/interfaces/ErrorHandling/README.md`
|
||||
|
||||
## How it works
|
||||
|
||||
Classification happens in the **data layer**: `handleError` in `data/fetchers.ts` tests the error message against `ERROR_PATTERNS` and throws the matching error subclass (e.g. `ConnectionTimeoutError extends ResponseError`). The component (`ErrorMatcher`) reads `errorType` from the instance and does an O(1) lookup — it never does regex matching.
|
||||
|
||||
```
|
||||
handleError() → throws ConnectionTimeoutError → React Query catches → ErrorMatcher reads errorType → renders troubleshooting
|
||||
```
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `data/error-patterns.ts` | Array of `{ pattern, ErrorClass }` — the regex lives here |
|
||||
| `types/api-errors.ts` | Error classes, `KnownErrorType` union, `ClassifiedError` type |
|
||||
| `ErrorMatcher.tsx` | Component — reads `errorType`, looks up mapping, renders |
|
||||
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting: ComponentType }>` |
|
||||
| `errorMappings/ConnectionTimeout.tsx` | Reference troubleshooting component |
|
||||
| `TroubleshootingSections.tsx` | Reusable accordion section components |
|
||||
| `TroubleshootingAccordion.tsx` | Accordion wrapper with telemetry |
|
||||
|
||||
## Usage
|
||||
|
||||
Pass the **full error object** from React Query — not `error.message`:
|
||||
|
||||
```tsx
|
||||
{
|
||||
isError && (
|
||||
<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{ projectRef }} />
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not pass `error.message` to `ErrorMatcher` — pass the full `error` object so the class is preserved.
|
||||
- Do not put regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`.
|
||||
- Do not use `Object.assign` to stamp `errorType` — throw a proper subclass instead.
|
||||
- Do not pass a raw URL string for support — use `supportFormParams={{ projectRef }}`.
|
||||
- Do not put the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller.
|
||||
- Do not add callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead.
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: studio-mock-api-tests
|
||||
description: Component tests for Supabase Studio that mock API requests at the
|
||||
network layer with MSW. Use when writing or reviewing a component test that
|
||||
exercises a React Query hook or mutation, or when migrating an existing
|
||||
test away from vi.mock('@/data/...'). Covers the customRender + addAPIMock
|
||||
template and the jsdom/MSW gotchas that cost real debugging time.
|
||||
---
|
||||
|
||||
# Studio MSW component tests
|
||||
|
||||
Mount a Studio component, intercept its network calls with MSW, assert
|
||||
what renders and what gets sent. The infrastructure is already wired up —
|
||||
this skill is the working template plus the gotchas.
|
||||
|
||||
## When to use
|
||||
|
||||
- The component (or any descendant it renders) calls a React Query hook
|
||||
or mutation that hits `/platform/...`, `/v1/...`, or another endpoint
|
||||
in `apps/studio/data/api.d.ts`.
|
||||
- You'd otherwise be tempted to write `vi.mock('@/data/some-query', ...)`.
|
||||
**Don't.** Mock the network instead — see "Why not vi.mock" below.
|
||||
|
||||
If the component is purely presentational with no data fetching, you
|
||||
don't need MSW; render and assert directly.
|
||||
|
||||
## The template
|
||||
|
||||
```tsx
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { MyComponent } from './MyComponent'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
// Needed if the component renders inside a Sheet, Modal, Popover, or
|
||||
// anything else built on Radix that uses Web Animations.
|
||||
mockAnimationsApi()
|
||||
|
||||
describe('MyComponent', () => {
|
||||
test('renders rows from the API', async () => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () =>
|
||||
HttpResponse.json<OrganizationResponse[]>([
|
||||
{
|
||||
/* ... */
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
customRender(<MyComponent />)
|
||||
|
||||
expect(await screen.findByText('Acme')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
That's the whole pattern. Server lifecycle (`listen`/`resetHandlers`/
|
||||
`close`) is handled by `apps/studio/tests/vitestSetup.ts` — handlers
|
||||
registered via `addAPIMock` are scoped to the current test.
|
||||
|
||||
## Gotchas that will eat your afternoon
|
||||
|
||||
### 1. Path params use `:slug`, not `{slug}`
|
||||
|
||||
`addAPIMock` is typed from the OpenAPI `paths`, but path params are
|
||||
remapped to MSW's `:param` format. Autocomplete will guide you, but if
|
||||
typecheck reports the path isn't assignable, you're using the OpenAPI
|
||||
`{slug}` form.
|
||||
|
||||
```ts
|
||||
// ❌ TypeScript error, MSW won't match
|
||||
path: '/platform/organizations/{slug}/projects'
|
||||
|
||||
// ✅ Correct
|
||||
path: '/platform/organizations/:slug/projects'
|
||||
```
|
||||
|
||||
### 2. Use `HttpResponse.json`, not `new HttpResponse`
|
||||
|
||||
For success responses, always go through `HttpResponse.json` — even for
|
||||
204/201-no-content endpoints. A raw `new HttpResponse(null, { status: 201 })`
|
||||
returns no content-type, and `openapi-fetch` can hang the mutation flow,
|
||||
which silently breaks `onSuccess` callbacks.
|
||||
|
||||
```ts
|
||||
// ❌ Mutation onSuccess silently never fires
|
||||
response: () => new HttpResponse(null, { status: 201 })
|
||||
|
||||
// ✅ Works (pass the OpenAPI body shape explicitly — see gotcha #8)
|
||||
response: () => HttpResponse.json<MyResponse>({}, { status: 201 })
|
||||
```
|
||||
|
||||
### 3. Submit buttons in Sheets/Modals need `fireEvent.click`
|
||||
|
||||
The convention `<Button form={FORM_ID} type="submit" />` (button
|
||||
outside the form, associated by id) doesn't reliably trigger submission
|
||||
under `userEvent.click` in jsdom. Use `fireEvent.click` for the submit
|
||||
button. Continue to use `userEvent.type` for inputs.
|
||||
|
||||
```ts
|
||||
await userEvent.type(screen.getByPlaceholderText('value'), 'hello')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
```
|
||||
|
||||
### 4. Profile-gated queries need a `profileContext`
|
||||
|
||||
Many hooks (`useOrganizationsQuery`, anything in `data/projects/`,
|
||||
anything that calls `useProfile`) refuse to fire until a profile is
|
||||
loaded. Pass one explicitly:
|
||||
|
||||
```ts
|
||||
import type { ProfileContextType } from '@/lib/profile'
|
||||
|
||||
const PROFILE_CONTEXT: ProfileContextType = {
|
||||
profile: {
|
||||
id: 1,
|
||||
auth0_id: 'auth0|test',
|
||||
gotrue_id: 'gotrue-test',
|
||||
username: 'testuser',
|
||||
primary_email: 'test@example.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
mobile: null,
|
||||
is_alpha_user: false,
|
||||
is_sso_user: false,
|
||||
disabled_features: [],
|
||||
free_project_limit: null,
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
}
|
||||
|
||||
customRender(<MyComponent />, { profileContext: PROFILE_CONTEXT })
|
||||
```
|
||||
|
||||
### 5. `useParams` is globally mocked to `{ ref: 'default' }`
|
||||
|
||||
You don't need to mock the Next router for project-scoped components.
|
||||
Just use `'default'` as the project ref in your mock paths:
|
||||
`/v1/projects/default/secrets`, `/platform/projects/default/...`. If
|
||||
you need a different ref, override with `routerMock.setCurrentUrl(...)`
|
||||
(see `apps/studio/tests/lib/route-mock.ts`).
|
||||
|
||||
### 6. Unhandled requests fail loudly — mock every endpoint a render triggers
|
||||
|
||||
`mswServer.listen({ onUnhandledRequest: 'error' })` is set globally. If a
|
||||
component (or any child it renders) fires an unmocked request, you'll see
|
||||
MSW errors in stderr and likely flaky behavior. Cards, lists, and details
|
||||
panels often fire nested queries (e.g. `OrganizationCard` calls
|
||||
`useOrgProjectsInfiniteQuery`) — read what the rendered subtree does and
|
||||
mock all of it, or stub it with `vi.mock` for nested components only.
|
||||
|
||||
### 7. Don't put query strings in the handler `path`
|
||||
|
||||
`addAPIMock` accepts `?foo=bar` suffixes via `TrimQueryParams`, but the
|
||||
helper strips them before matching. MSW v2 doesn't match query params via
|
||||
path strings — read them inside the resolver instead:
|
||||
|
||||
```ts
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects',
|
||||
response: ({ request }) => {
|
||||
const limit = new URL(request.url).searchParams.get('limit')
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 8. Always pass an explicit generic to `HttpResponse.json`
|
||||
|
||||
`addAPIMock`'s resolver is typed against the OpenAPI success body (and the
|
||||
standard `{ message: string }` error envelope, exported as `APIErrorBody`).
|
||||
But MSW's `HttpResponse.json` uses `NoInfer`, so the body type doesn't
|
||||
narrow from context. Pass the expected shape explicitly — it doubles as a
|
||||
self-documenting contract assertion:
|
||||
|
||||
```ts
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
|
||||
response: () => HttpResponse.json<OrganizationResponse[]>([...])
|
||||
response: () =>
|
||||
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
|
||||
```
|
||||
|
||||
A mock that drifts from the contract (wrong envelope, missing fields,
|
||||
stale enum values) now fails at compile time, not at runtime. The cost is
|
||||
one type annotation per resolver — well worth it.
|
||||
|
||||
For mocks at the network boundary, also prefer `createMockOrganizationResponse`
|
||||
(returns the raw OpenAPI `OrganizationResponse`) over `createMockOrganization`
|
||||
(which extends with frontend-derived `managed_by` / `partner_id` that the
|
||||
query layer attaches). Same pattern applies to any type that's a frontend
|
||||
extension of an OpenAPI schema: build a `createMockXResponse` helper that
|
||||
returns the raw API shape.
|
||||
|
||||
## Prefer asserting on UI state
|
||||
|
||||
MSW's own best-practices doc explicitly recommends asserting on what
|
||||
renders, not on whether a handler was called. The "did the form
|
||||
submit?" question is best answered by `expect(onClose).toHaveBeenCalled()`
|
||||
or by `findByText('Saved')` — not by spying on the resolver.
|
||||
|
||||
There's one legitimate exception: **the request body itself is the
|
||||
contract you care about**, and the server's reply doesn't reflect it
|
||||
back. Bulk-create endpoints (like `POST /v1/projects/:ref/secrets`) are
|
||||
the canonical case — 201 with no body, so the only way to verify the
|
||||
shape sent is to capture it:
|
||||
|
||||
```ts
|
||||
const requests: Array<{ ref: string | undefined; body: unknown }> = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/v1/projects/:ref/secrets',
|
||||
response: async ({ request, params }) => {
|
||||
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
|
||||
return HttpResponse.json<CreateSecretsResponse>({}, { status: 201 })
|
||||
},
|
||||
})
|
||||
|
||||
// ... drive the UI ...
|
||||
|
||||
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
|
||||
```
|
||||
|
||||
When in doubt, assert on the UI first; reach for request capture only
|
||||
when the UI doesn't observably encode the contract.
|
||||
|
||||
## Debugging an MSW test
|
||||
|
||||
If a request isn't being matched, wire up MSW's lifecycle events at the
|
||||
top of the test file (or temporarily in `msw.ts`):
|
||||
|
||||
```ts
|
||||
import { mswServer } from '@/tests/lib/msw'
|
||||
|
||||
mswServer.events.on('request:unhandled', ({ request }) => {
|
||||
console.log('[MSW] UNHANDLED:', request.method, request.url)
|
||||
})
|
||||
mswServer.events.on('response:mocked', ({ request, response }) => {
|
||||
console.log('[MSW] MATCHED:', request.method, request.url, response.status)
|
||||
})
|
||||
```
|
||||
|
||||
`request:start` is already wired in `msw.ts`. Add `request:unhandled` and
|
||||
`response:mocked` locally when a test misbehaves — usually surfaces a
|
||||
path-param mismatch or a nested query you forgot to mock.
|
||||
|
||||
## Why not `vi.mock('@/data/...')`
|
||||
|
||||
It bypasses the network boundary, so:
|
||||
|
||||
- It hides real bugs: a renamed query key or a changed request payload
|
||||
passes the test, then breaks in production.
|
||||
- It doesn't exercise React Query's caching, retry, or invalidation
|
||||
paths — `onMutate`, `onSuccess`, and `onError` callbacks won't run as
|
||||
they do in real life. ([tkdodo.eu/blog/testing-react-query](https://tkdodo.eu/blog/testing-react-query))
|
||||
- It drifts independently from the OpenAPI types — handlers stay in sync,
|
||||
module-level mocks don't.
|
||||
|
||||
Reach for `vi.mock` only for non-network concerns: a heavy child
|
||||
component (e.g. a Monaco editor) you want to stub, or a `common`-package
|
||||
hook with global state.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [TkDodo — Testing React Query](https://tkdodo.eu/blog/testing-react-query) —
|
||||
canonical reference for the principles behind everything in this skill.
|
||||
- [MSW best practices: structuring handlers](https://mswjs.io/docs/best-practices/structuring-handlers/)
|
||||
and [overriding network behavior](https://mswjs.io/docs/best-practices/network-behavior-overrides/) —
|
||||
the baseline-handlers + per-test-`server.use()` pattern.
|
||||
- [MSW best practices: avoid request assertions](https://mswjs.io/docs/best-practices/avoid-request-assertions/) —
|
||||
the source of the "assert on UI state" guidance above.
|
||||
|
||||
## Codebase references
|
||||
|
||||
| What | Where |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Query-only example (loading, error, success) | `apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx` |
|
||||
| Mutation example (form, payload assertion) | `apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx` |
|
||||
| SQL-via-pg-meta example (POST resolver branch on `query` body) | `apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx` |
|
||||
| `addAPIMock` source | `apps/studio/tests/lib/msw.ts` |
|
||||
| `customRender` source | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| Global handlers + lifecycle | `apps/studio/tests/lib/msw-global-api-mocks.ts`, `apps/studio/tests/vitestSetup.ts` |
|
||||
| Related skills | `studio-testing` (when to write a component test at all), `studio-queries` (hook conventions), `vitest` |
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: studio-queries
|
||||
description: React Query conventions for data fetching in Supabase Studio. Use when
|
||||
writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/.
|
||||
Covers queryOptions pattern, keys.ts structure, mutation hook template, and imperative
|
||||
fetching.
|
||||
---
|
||||
|
||||
# Studio Queries & Mutations (React Query)
|
||||
|
||||
Follow the patterns in `apps/studio/data/`. Reference examples:
|
||||
|
||||
- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
|
||||
- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
|
||||
- Keys: `apps/studio/data/edge-functions/keys.ts`
|
||||
|
||||
## Query Keys
|
||||
|
||||
Define a `keys.ts` per domain. Export `*Keys` helpers using array keys with `as const`. Never inline query keys in components.
|
||||
|
||||
```ts
|
||||
export const edgeFunctionsKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
|
||||
detail: (projectRef: string | undefined, slug: string | undefined) =>
|
||||
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
|
||||
}
|
||||
```
|
||||
|
||||
## Query Options (preferred pattern)
|
||||
|
||||
Use `queryOptions` from `@tanstack/react-query`. This gives type safety and works with both `useQuery()` and `queryClient.fetchQuery()`.
|
||||
|
||||
Rules:
|
||||
|
||||
- Export `XVariables`, `XData`, and `XError` types (prefixed with the domain name)
|
||||
- Implement a **private** `getX(variables, signal?)` function:
|
||||
- Throws if required variables are missing
|
||||
- Passes `signal` for cancellation
|
||||
- Calls `handleError(error)` on failure (which throws); returns `data` on success
|
||||
- Not exported — use `queryClient.fetchQuery(xQueryOptions(...))` for imperative fetching
|
||||
- Export `xQueryOptions()` using `queryOptions`
|
||||
- Gate with `enabled` so the query doesn't run until required variables exist
|
||||
- Platform-only queries: include `IS_PLATFORM` from `lib/constants` in `enabled`
|
||||
- Don't add extra params to `xQueryOptions` — callers override by destructuring: `{ ...xQueryOptions(vars), enabled: true }`
|
||||
|
||||
```ts
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
export type XVariables = { projectRef?: string }
|
||||
export type XError = ResponseError
|
||||
|
||||
async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
const { data, error } = await get('/v1/projects/{ref}/x', {
|
||||
params: { path: { ref: projectRef } },
|
||||
signal,
|
||||
})
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type XData = Awaited<ReturnType<typeof getX>>
|
||||
|
||||
export const xQueryOptions = ({ projectRef }: XVariables) =>
|
||||
queryOptions({
|
||||
queryKey: xKeys.list(projectRef),
|
||||
queryFn: ({ signal }) => getX({ projectRef }, signal),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
|
||||
})
|
||||
```
|
||||
|
||||
## Using Query Options in Components
|
||||
|
||||
```ts
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { xQueryOptions } from '@/data/x/x-query'
|
||||
|
||||
const { data, isPending, isError } = useQuery(xQueryOptions({ projectRef: project?.ref }))
|
||||
```
|
||||
|
||||
## Imperative Fetching (outside React or in callbacks)
|
||||
|
||||
```ts
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const handleClick = useCallback(
|
||||
async (id: number) => {
|
||||
const data = await queryClient.fetchQuery(xQueryOptions({ id, projectRef: project?.ref }))
|
||||
// use data...
|
||||
},
|
||||
[project?.ref, queryClient]
|
||||
)
|
||||
```
|
||||
|
||||
## Mutation Hook
|
||||
|
||||
- Export a `Variables` type with `projectRef`, identifiers, and `payload`
|
||||
- Implement a private `updateX(vars)` function with required variable validation and `handleError`
|
||||
- Wrap in `useXMutation()`:
|
||||
- Accepts `UseMutationOptions` (omit `mutationFn`)
|
||||
- Invalidates `list()` + `detail()` keys in `onSuccess` with `await Promise.all([...])`
|
||||
- Defaults to `toast.error(...)` when `onError` isn't provided
|
||||
|
||||
```ts
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { xKeys } from './keys'
|
||||
|
||||
type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
|
||||
|
||||
export const useXUpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: UseMutationOptions<XData, XError, XUpdateVariables> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: updateX,
|
||||
async onSuccess(data, variables, context) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: xKeys.detail(variables.projectRef, variables.slug),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
|
||||
])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(error, variables, context) {
|
||||
if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
|
||||
else onError(error, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Component Usage
|
||||
|
||||
- Use React Query v5 flags: `isPending` for initial load, `isFetching` for background refetches
|
||||
- Render states explicitly in order: pending → error → success
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: studio-testing
|
||||
description: Testing strategy for Supabase Studio. Use when writing tests, deciding what
|
||||
type of test to write, extracting logic from components into testable utility
|
||||
functions, or reviewing test coverage. Covers unit tests, component tests,
|
||||
and E2E test selection criteria.
|
||||
---
|
||||
|
||||
# Studio Testing Strategy
|
||||
|
||||
How to write and structure tests for `apps/studio/`. The core principle: push
|
||||
logic out of React components into pure utility functions, then test those
|
||||
functions exhaustively. Only use component tests for complex UI interactions.
|
||||
Use E2E tests for features shared between self-hosted and platform.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new tests for Studio code
|
||||
- Deciding which type of test to write (unit, component, E2E)
|
||||
- Extracting logic from a component to make it testable
|
||||
- Reviewing whether test coverage is sufficient
|
||||
- Adding a new feature that needs tests
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ---------------- | -------- | ---------- |
|
||||
| 1 | Logic Extraction | CRITICAL | `testing-` |
|
||||
| 2 | Test Coverage | CRITICAL | `testing-` |
|
||||
| 3 | Component Tests | HIGH | `testing-` |
|
||||
| 4 | E2E Tests | HIGH | `testing-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Logic Extraction (CRITICAL)
|
||||
|
||||
- `testing-extract-logic` - Remove logic from components into `.utils.ts` files
|
||||
as pure functions: args in, return out
|
||||
|
||||
### 2. Test Coverage (CRITICAL)
|
||||
|
||||
- `testing-exhaustive-permutations` - Test every permutation of utility functions:
|
||||
happy path, malformed input, empty values, edge cases
|
||||
|
||||
### 3. Component Tests (HIGH)
|
||||
|
||||
- `testing-component-tests-ui-only` - Only write component tests for complex UI
|
||||
interaction logic, not business logic
|
||||
|
||||
### 4. E2E Tests (HIGH)
|
||||
|
||||
- `testing-e2e-shared-features` - Write E2E tests for features used in both
|
||||
self-hosted and platform; cover clicks AND keyboard shortcuts
|
||||
|
||||
## Decision Tree: Which Test Type?
|
||||
|
||||
```
|
||||
Is the logic a pure transformation (parse, format, validate, compute)?
|
||||
YES -> Extract to .utils.ts, write unit test with vitest
|
||||
NO -> Does the feature involve complex UI interactions?
|
||||
YES -> Is it used in both self-hosted and platform?
|
||||
YES -> Write E2E test in e2e/studio/features/
|
||||
NO -> Write component test with customRender
|
||||
NO -> Can you extract the logic to make it pure?
|
||||
YES -> Do that, then unit test it
|
||||
NO -> Write a component test
|
||||
```
|
||||
|
||||
## 1. Extract Logic Into Utility Files (CRITICAL)
|
||||
|
||||
Remove as much logic from components as possible. Put it in co-located
|
||||
`.utils.ts` files as pure functions: arguments in, return value out.
|
||||
|
||||
**File naming:**
|
||||
|
||||
- Utility: `ComponentName.utils.ts` next to the component
|
||||
- Test: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
|
||||
|
||||
```tsx
|
||||
// ❌ Logic buried in component — hard to test without rendering
|
||||
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
|
||||
const handleSubmit = () => {
|
||||
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
|
||||
let sanitized = taxIdValue
|
||||
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
|
||||
sanitized = taxId.vatPrefix + taxIdValue
|
||||
}
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
return <form onSubmit={handleSubmit}>...</form>
|
||||
}
|
||||
|
||||
// ✅ Logic extracted to .utils.ts — trivially testable
|
||||
// TaxID.utils.ts
|
||||
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
|
||||
const taxId = TAX_IDS.find((t) => t.name === name)
|
||||
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
|
||||
return taxId.vatPrefix + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// TaxIdForm.tsx — thin shell
|
||||
const handleSubmit = () => {
|
||||
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
|
||||
submitToApi(sanitized)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Test Every Permutation (CRITICAL)
|
||||
|
||||
Once logic is extracted, test exhaustively. Every code path needs a test:
|
||||
|
||||
- Valid inputs (happy path for each branch)
|
||||
- Invalid / malformed inputs
|
||||
- Empty values, null values, missing fields
|
||||
- Edge cases (timestamps with colons, special characters, boundary values)
|
||||
|
||||
```ts
|
||||
// ❌ Only happy path
|
||||
test('parses a filter', () => {
|
||||
expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
|
||||
})
|
||||
|
||||
// ✅ Every permutation
|
||||
test('parses valid filter', () => { ... })
|
||||
test('handles timestamp with colons in value', () => { ... })
|
||||
test('rejects malformed filter with missing parts', () => { ... })
|
||||
test('rejects unrecognized operator', () => { ... })
|
||||
test('allows empty filter value', () => { ... })
|
||||
```
|
||||
|
||||
## 3. Component Tests for Complex UI Only (HIGH)
|
||||
|
||||
Only write component tests when there is complex UI interaction logic that
|
||||
cannot be captured by testing utility functions alone.
|
||||
|
||||
**Valid reasons:** conditional rendering from user interaction sequences,
|
||||
popover open/close with keyboard/mouse, multi-step form transitions.
|
||||
|
||||
**Not valid:** testing a calculation or transformation that happens to live
|
||||
in a component — extract to `.utils.ts` and unit test instead.
|
||||
|
||||
```tsx
|
||||
// Studio component test conventions
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { customRender } from 'tests/lib/custom-render' // always use customRender, not raw render
|
||||
import { addAPIMock } from 'tests/lib/msw' // API mocking in beforeEach
|
||||
```
|
||||
|
||||
## 4. E2E Tests for Shared Features (HIGH)
|
||||
|
||||
If a feature exists in both self-hosted and platform, create an E2E test.
|
||||
Cover mouse clicks AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys).
|
||||
|
||||
Extract reusable interactions into `e2e/studio/utils/*-helpers.ts`. Use
|
||||
try/finally for resource cleanup. For E2E execution details, see the
|
||||
`studio-e2e-tests` skill.
|
||||
|
||||
## Codebase References
|
||||
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Util test examples | `apps/studio/tests/components/Grid/Grid.utils.test.ts`, `apps/studio/tests/components/Billing/TaxID.utils.test.ts`, `apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts` |
|
||||
| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `apps/studio/tests/README.md` |
|
||||
| Vitest config | `apps/studio/vitest.config.ts` |
|
||||
| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: studio-ui-patterns
|
||||
description: Design system UI patterns for Supabase Studio. Use when building or updating
|
||||
pages, forms, tables, charts, empty states, navigation, cards, alerts, or side panels
|
||||
(sheets). Covers layout selection, component choice, and placement conventions.
|
||||
---
|
||||
|
||||
# Studio UI Patterns
|
||||
|
||||
The Design System docs and demos are the source of truth. Always check the relevant
|
||||
demo file before composing new UI.
|
||||
|
||||
## Layout
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/layout.mdx`
|
||||
|
||||
Build pages with `PageContainer`, `PageHeader`, and `PageSection`.
|
||||
|
||||
| Content type | `size` |
|
||||
| ----------------- | ----------- |
|
||||
| Settings / config | `"default"` |
|
||||
| Lists / tables | `"large"` |
|
||||
| Full-screen views | `"full"` |
|
||||
|
||||
- If filters/search exist on a list page, align table actions with the filters (don't use `PageHeaderAside`/`PageSectionAside` for those actions)
|
||||
- If no filters, actions can go in `PageHeaderAside` or `PageSectionAside`
|
||||
|
||||
Demos: `page-layout-settings.tsx`, `page-layout-list.tsx`, `page-layout-list-simple.tsx`, `page-layout-detail.tsx`
|
||||
(all in `apps/design-system/registry/default/example/`)
|
||||
|
||||
## Forms
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/forms.mdx`
|
||||
|
||||
- Use `react-hook-form` + `zod`
|
||||
- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`
|
||||
- Wrap inputs with `FormControl`; use `_Shadcn_` imports from `ui` for primitives
|
||||
|
||||
Layout selection:
|
||||
|
||||
| Context | Layout | Container |
|
||||
| ------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| Page (settings/config) | `FormItemLayout layout="flex-row-reverse"` | `Card` (`CardContent` per field; `CardFooter` for actions) |
|
||||
| Side panel — wide | `FormItemLayout layout="horizontal"` | `SheetSection` |
|
||||
| Side panel — narrow (`size="sm"` or below) | `FormItemLayout layout="vertical"` | `SheetSection` |
|
||||
|
||||
Dirty state / submit:
|
||||
|
||||
- Destructure `isDirty` from `form.formState` to show Cancel and disable Save
|
||||
- Show loading on submit button via `loading` prop
|
||||
- If submit button is outside `<form>`, set a stable `formId` and use `form` prop on the button
|
||||
|
||||
Demos: `form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`
|
||||
|
||||
## Tables
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/tables.mdx`
|
||||
|
||||
| Pattern | Use when |
|
||||
| ---------- | ----------------------------------------------------------------------- |
|
||||
| `Table` | Simple, static, semantic display |
|
||||
| Data Table | TanStack-powered; sorting, filtering, pagination; composed per use-case |
|
||||
| Data Grid | Virtualization, column resizing, or complex cell editing |
|
||||
|
||||
- Actions: above the table, aligned right
|
||||
- Search/filters: above the table, aligned left
|
||||
- If table is primary content with no filters, actions can live in the page's primary/secondary actions area
|
||||
|
||||
Demos: `table-demo.tsx`, `data-table-demo.tsx`, `data-grid-demo.tsx`
|
||||
|
||||
## Charts
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/charts.mdx`
|
||||
|
||||
- Use provided chart building blocks; avoid passing raw Recharts components to `ChartContent`
|
||||
- Use `useChart` context flags for loading/disabled states
|
||||
- Keep composition straightforward — avoid over-abstraction
|
||||
|
||||
Demos (in `apps/design-system/__registry__/default/block/`): `chart-composed-demo.tsx`, `chart-composed-basic.tsx`, `chart-composed-states.tsx`, `chart-composed-metrics.tsx`, `chart-composed-actions.tsx`, `chart-composed-table.tsx`
|
||||
|
||||
## Empty States
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/empty-states.mdx`
|
||||
|
||||
| Scenario | Pattern |
|
||||
| ------------------------ | ------------------------------------------------------------------- |
|
||||
| Initial / onboarding | Presentational empty state with value prop + clear next action |
|
||||
| Data-heavy lists | Informational empty state matching the list/table layout |
|
||||
| Zero results from search | Keep layout consistent with data state to avoid jarring transitions |
|
||||
| Missing route | Centered `Admonition` |
|
||||
|
||||
Demos: `empty-state-presentational-icon.tsx`, `empty-state-initial-state-informational.tsx`, `empty-state-zero-items-table.tsx`, `data-grid-empty-state.tsx`, `empty-state-missing-route.tsx`
|
||||
|
||||
## Navigation
|
||||
|
||||
Docs: `apps/design-system/content/docs/ui-patterns/navigation.mdx`
|
||||
|
||||
- Use `NavMenu` for a horizontal list of related views within a consistent page layout
|
||||
- Activating an item must trigger a **URL change** — no local-only tab state
|
||||
|
||||
## Cards
|
||||
|
||||
- Group related information in cards
|
||||
- `CardContent` for sections, `CardFooter` for actions
|
||||
- Only use `CardHeader`/`CardTitle` when context isn't already provided by surrounding content
|
||||
- Use headers/titles when multiple cards represent distinct groups (e.g. multiple settings sections)
|
||||
|
||||
## Alerts
|
||||
|
||||
- Use `Admonition` to call out important actions, restrictions, or critical context
|
||||
- Place at the **top of a page's content** (below page title) or **top of the relevant section** (below section title)
|
||||
- Use sparingly
|
||||
|
||||
## Sheets (Side Panels)
|
||||
|
||||
Use a `Sheet` when switching pages would be disruptive and the user needs to maintain context (e.g. selecting a row from a list to edit).
|
||||
|
||||
Structure:
|
||||
|
||||
- `SheetContent` with `size="lg"` for forms needing horizontal layout
|
||||
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, `SheetFooter`
|
||||
- Submit/cancel actions go in `SheetFooter`
|
||||
|
||||
Forms in sheets:
|
||||
|
||||
- `layout="horizontal"` for wider sheets
|
||||
- `layout="vertical"` for narrow sheets (`size="sm"` or below)
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: telemetry-standards
|
||||
description: PostHog event tracking standards for Supabase Studio. Use when reviewing
|
||||
PRs for telemetry compliance or implementing new event tracking. Covers event naming,
|
||||
property conventions, approved patterns, and implementation guide.
|
||||
---
|
||||
|
||||
# Telemetry Standards for Supabase Studio
|
||||
|
||||
Standards for PostHog event tracking in `apps/studio/`. Apply these when
|
||||
reviewing PRs that touch tracking or when implementing new tracking.
|
||||
|
||||
## Event Naming
|
||||
|
||||
**Format:** `[object]_[verb]` in snake_case
|
||||
|
||||
**Approved verbs only** (canonical list — derived from `packages/common/telemetry-constants.ts`):
|
||||
opened, clicked, submitted, created, removed, updated, intended, evaluated, added,
|
||||
enabled, disabled, copied, exposed, failed, converted, closed, completed, applied, sent, moved
|
||||
|
||||
**Flag these:**
|
||||
- Unapproved verbs (saved, viewed, seen, pressed, etc.)
|
||||
- Wrong order: `click_product_card` → should be `product_card_clicked`
|
||||
- Wrong casing: `productCardClicked` → should be `product_card_clicked`
|
||||
|
||||
**Good examples:**
|
||||
- `product_card_clicked`
|
||||
- `backup_button_clicked`
|
||||
- `sql_query_submitted`
|
||||
|
||||
**Common mistakes with corrections:**
|
||||
- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb)
|
||||
- `click_backup_button` → `backup_button_clicked` (wrong order)
|
||||
- `dashboardViewed` → don't track passive views on page load
|
||||
- `component_rendered` → don't track — no user interaction
|
||||
|
||||
## Property Standards
|
||||
|
||||
**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention.
|
||||
|
||||
**Names must be self-explanatory:**
|
||||
- `{ productType: 'database', planTier: 'pro' }`
|
||||
- `{ assistantType: 'sql', suggestionType: 'optimization' }`
|
||||
|
||||
**Flag these:**
|
||||
- Generic names: `label`, `value`, `name`, `data`
|
||||
- PascalCase properties
|
||||
- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event)
|
||||
- Mixing camelCase and snake_case within the same event
|
||||
|
||||
## What NOT to Track
|
||||
|
||||
- Passive views/renders on page load (`dashboard_viewed`, `sidebar_appeared`, `page_loaded`)
|
||||
- Component appearances without user interaction
|
||||
- Generic "viewed" or "seen" events — already captured by pageview events
|
||||
|
||||
**DO track:** user clicks, form submissions, explicit opens/closes, user-initiated actions.
|
||||
|
||||
**Exception:** `_exposed` events for A/B experiment exposure tracking are valid even though they fire on render.
|
||||
|
||||
**Never track PII** (emails, names, IPs, etc.) in event properties.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
Import `useTrack` from `lib/telemetry/track` (within `apps/studio/`). Never use `useSendEventMutation` (deprecated).
|
||||
|
||||
```typescript
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
|
||||
const MyComponent = () => {
|
||||
const track = useTrack()
|
||||
|
||||
const handleClick = () => {
|
||||
track('product_card_clicked', {
|
||||
productType: 'database',
|
||||
planTier: 'pro',
|
||||
source: 'dashboard',
|
||||
})
|
||||
}
|
||||
|
||||
return <button onClick={handleClick}>Click me</button>
|
||||
}
|
||||
```
|
||||
|
||||
## Event Definitions
|
||||
|
||||
All events must be defined as TypeScript interfaces in `packages/common/telemetry-constants.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* [Event description]
|
||||
*
|
||||
* @group Events
|
||||
* @source [what triggers this event]
|
||||
*/
|
||||
export interface MyFeatureClickedEvent {
|
||||
action: 'my_feature_clicked'
|
||||
properties: {
|
||||
/** Description of property */
|
||||
featureType: string
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
```
|
||||
|
||||
Add the new interface to the `TelemetryEvent` union type so `useTrack` picks it up.
|
||||
`@group Events` and `@source` must be accurate.
|
||||
|
||||
## Review Rules
|
||||
|
||||
When reviewing a PR, flag these as **required changes:**
|
||||
|
||||
1. **Naming violations** — event not following `[object]_[verb]` snake_case, or using an unapproved verb
|
||||
2. **Property violations** — not camelCase, generic names, or inconsistent with similar events
|
||||
3. **Deprecated hook** — any usage of `useSendEventMutation` instead of `useTrack`
|
||||
4. **Unnecessary view tracking** — events that fire on page load without user interaction
|
||||
5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation
|
||||
|
||||
When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest:
|
||||
- "This adds a user interaction that may benefit from tracking."
|
||||
- Propose the event name following `[object]_[verb]` convention
|
||||
- Propose the `useTrack()` call with suggested properties
|
||||
|
||||
When checking property consistency, search `packages/common/telemetry-constants.ts` for similar events and verify property names match.
|
||||
|
||||
## Well-Formed Event Examples
|
||||
|
||||
From the actual codebase:
|
||||
|
||||
```typescript
|
||||
// User copies a connection string
|
||||
track('connection_string_copied', {
|
||||
connectionType: 'psql',
|
||||
connectionMethod: 'transaction_pooler',
|
||||
connectionTab: 'Connection String',
|
||||
})
|
||||
|
||||
// User enables a feature preview
|
||||
track('feature_preview_enabled', {
|
||||
feature: 'realtime_inspector',
|
||||
})
|
||||
|
||||
// User clicks a banner CTA
|
||||
track('index_advisor_banner_dismiss_button_clicked')
|
||||
|
||||
// Experiment exposure (fires on render — valid exception)
|
||||
track('home_new_experiment_exposed', {
|
||||
variant: 'treatment',
|
||||
})
|
||||
```
|
||||
|
||||
## Implementing New Tracking
|
||||
|
||||
To add tracking for a user action:
|
||||
|
||||
1. **Name the event** — `[object]_[verb]` using approved verbs only
|
||||
2. **Choose properties** — camelCase preferred for new events; check `packages/common/telemetry-constants.ts` for similar events and match their property names and casing
|
||||
3. **Add interface to telemetry-constants.ts** — with `@group Events` and `@source` JSDoc, add to the `TelemetryEvent` union type
|
||||
4. **Add to component** — `import { useTrack } from 'lib/telemetry/track'`, call `track('event_name', { properties })`
|
||||
|
||||
### Verification checklist
|
||||
|
||||
- [ ] Event name follows `[object]_[verb]` with approved verb
|
||||
- [ ] Event name is snake_case
|
||||
- [ ] Properties are camelCase and self-explanatory
|
||||
- [ ] Event defined in telemetry-constants.ts with accurate `@page`/`@source`
|
||||
- [ ] Using `useTrack` hook (not `useSendEventMutation`)
|
||||
- [ ] Not tracking passive views/appearances
|
||||
- [ ] No PII in event properties (emails, names, IPs, etc.)
|
||||
- [ ] Property names consistent with similar events
|
||||
@@ -0,0 +1,946 @@
|
||||
# React Composition Patterns
|
||||
|
||||
**Version 1.0.0**
|
||||
Engineering
|
||||
January 2026
|
||||
|
||||
> **Note:**
|
||||
> This document is mainly for agents and LLMs to follow when maintaining,
|
||||
> generating, or refactoring React codebases using composition. Humans
|
||||
> may also find it useful, but guidance here is optimized for automation
|
||||
> and consistency by AI-assisted workflows.
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Architecture](#1-component-architecture) — **HIGH**
|
||||
- 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
|
||||
- 1.2 [Use Compound Components](#12-use-compound-components)
|
||||
2. [State Management](#2-state-management) — **MEDIUM**
|
||||
- 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
|
||||
- 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
|
||||
- 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
|
||||
3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
|
||||
- 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
|
||||
- 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
|
||||
4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
|
||||
- 4.1 [React 19 API Changes](#41-react-19-api-changes)
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Architecture
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Fundamental patterns for structuring components to avoid prop
|
||||
proliferation and enable flexible composition.
|
||||
|
||||
### 1.1 Avoid Boolean Prop Proliferation
|
||||
|
||||
**Impact: CRITICAL (prevents unmaintainable component variants)**
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect: boolean props create exponential complexity**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: composition eliminates conditionals**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
|
||||
sharing a single monolithic parent.
|
||||
|
||||
### 1.2 Use Compound Components
|
||||
|
||||
**Impact: HIGH (enables flexible composition without prop drilling)**
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
|
||||
pieces they need.
|
||||
|
||||
**Incorrect: monolithic component with render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: compound components with shared context**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Management
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Patterns for lifting state and managing shared context across
|
||||
composed components.
|
||||
|
||||
### 2.1 Decouple State Management from UI
|
||||
|
||||
**Impact: MEDIUM (enables swapping state implementations without changing UI)**
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect: UI coupled to state implementation**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state management isolated in provider**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
|
||||
depends on the context interface, not the implementation.
|
||||
|
||||
### 2.2 Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
**Impact: HIGH (enables dependency-injectable state across use-cases)**
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect: UI coupled to specific state implementation**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: generic interface enables dependency injection**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
|
||||
to be within the provider.
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
|
||||
### 2.3 Lift State into Provider Components
|
||||
|
||||
**Impact: HIGH (enables state sharing outside component boundaries)**
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect: state trapped inside component**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useEffect to sync state up**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: reading state from ref on submit**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state lifted to provider**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Patterns
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Specific techniques for implementing compound components and
|
||||
context providers.
|
||||
|
||||
### 3.1 Create Explicit Component Variants
|
||||
|
||||
**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
|
||||
itself.
|
||||
|
||||
**Incorrect: one component, many modes**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct: explicit variants**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
|
||||
- What UI elements it includes
|
||||
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
|
||||
### 3.2 Prefer Composing Children Over Render Props
|
||||
|
||||
**Impact: MEDIUM (cleaner composition, better readability)**
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
|
||||
signatures.
|
||||
|
||||
**Incorrect: render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct: compound components with children**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
|
||||
Use children when composing static structure.
|
||||
|
||||
---
|
||||
|
||||
## 4. React 19 APIs
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
|
||||
|
||||
### 4.1 React 19 API Changes
|
||||
|
||||
**Impact: MEDIUM (cleaner component definitions and context usage)**
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect: forwardRef in React 19**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct: ref as a regular prop**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useContext in React 19**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct: use instead of useContext**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. [https://react.dev](https://react.dev)
|
||||
2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
|
||||
3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: vercel-composition-patterns
|
||||
description: React composition patterns that scale. Use when refactoring components with
|
||||
boolean prop proliferation, building flexible component libraries, or
|
||||
designing reusable APIs. Triggers on tasks involving compound components,
|
||||
render props, context providers, or component architecture. Includes React 19
|
||||
API changes.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# React Composition Patterns
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid
|
||||
boolean prop proliferation by using compound components, lifting state, and
|
||||
composing internals. These patterns make codebases easier for both humans and AI
|
||||
agents to work with as they scale.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Refactoring components with many boolean props
|
||||
- Building reusable component libraries
|
||||
- Designing flexible component APIs
|
||||
- Reviewing component architecture
|
||||
- Working with compound components or context providers
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ----------------------- | ------ | --------------- |
|
||||
| 1 | Component Architecture | HIGH | `architecture-` |
|
||||
| 2 | State Management | MEDIUM | `state-` |
|
||||
| 3 | Implementation Patterns | MEDIUM | `patterns-` |
|
||||
| 4 | React 19 APIs | MEDIUM | `react19-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Component Architecture (HIGH)
|
||||
|
||||
- `architecture-avoid-boolean-props` - Don't add boolean props to customize
|
||||
behavior; use composition
|
||||
- `architecture-compound-components` - Structure complex components with shared
|
||||
context
|
||||
|
||||
### 2. State Management (MEDIUM)
|
||||
|
||||
- `state-decouple-implementation` - Provider is the only place that knows how
|
||||
state is managed
|
||||
- `state-context-interface` - Define generic interface with state, actions, meta
|
||||
for dependency injection
|
||||
- `state-lift-state` - Move state into provider components for sibling access
|
||||
|
||||
### 3. Implementation Patterns (MEDIUM)
|
||||
|
||||
- `patterns-explicit-variants` - Create explicit variant components instead of
|
||||
boolean modes
|
||||
- `patterns-children-over-render-props` - Use children for composition instead
|
||||
of renderX props
|
||||
|
||||
### 4. React 19 APIs (MEDIUM)
|
||||
|
||||
> **⚠️ React 19+ only.** Skip these patterns if you're on React 18 or earlier.
|
||||
|
||||
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/architecture-avoid-boolean-props.md
|
||||
rules/state-context-interface.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Avoid Boolean Prop Proliferation
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents unmaintainable component variants
|
||||
tags: composition, props, architecture
|
||||
---
|
||||
|
||||
## Avoid Boolean Prop Proliferation
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect (boolean props create exponential complexity):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (composition eliminates conditionals):**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
sharing a single monolithic parent.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Use Compound Components
|
||||
impact: HIGH
|
||||
impactDescription: enables flexible composition without prop drilling
|
||||
tags: composition, compound-components, architecture
|
||||
---
|
||||
|
||||
## Use Compound Components
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
pieces they need.
|
||||
|
||||
**Incorrect (monolithic component with render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (compound components with shared context):**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Prefer Composing Children Over Render Props
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner composition, better readability
|
||||
tags: composition, children, render-props
|
||||
---
|
||||
|
||||
## Prefer Children Over Render Props
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
signatures.
|
||||
|
||||
**Incorrect (render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (compound components with children):**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
Use children when composing static structure.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Create Explicit Component Variants
|
||||
impact: MEDIUM
|
||||
impactDescription: self-documenting code, no hidden conditionals
|
||||
tags: composition, variants, architecture
|
||||
---
|
||||
|
||||
## Create Explicit Component Variants
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
itself.
|
||||
|
||||
**Incorrect (one component, many modes):**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct (explicit variants):**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
- What UI elements it includes
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: React 19 API Changes
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner component definitions and context usage
|
||||
tags: react19, refs, context, hooks
|
||||
---
|
||||
|
||||
## React 19 API Changes
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect (forwardRef in React 19):**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (ref as a regular prop):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useContext in React 19):**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct (use instead of useContext):**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Define Generic Context Interfaces for Dependency Injection
|
||||
impact: HIGH
|
||||
impactDescription: enables dependency-injectable state across use-cases
|
||||
tags: composition, context, state, typescript, dependency-injection
|
||||
---
|
||||
|
||||
## Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect (UI coupled to specific state implementation):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (generic interface enables dependency injection):**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
to be within the provider.
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Decouple State Management from UI
|
||||
impact: MEDIUM
|
||||
impactDescription: enables swapping state implementations without changing UI
|
||||
tags: composition, state, architecture
|
||||
---
|
||||
|
||||
## Decouple State Management from UI
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect (UI coupled to state implementation):**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state management isolated in provider):**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
depends on the context interface, not the implementation.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Lift State into Provider Components
|
||||
impact: HIGH
|
||||
impactDescription: enables state sharing outside component boundaries
|
||||
tags: composition, state, context, providers
|
||||
---
|
||||
|
||||
## Lift State into Provider Components
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect (state trapped inside component):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useEffect to sync state up):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (reading state from ref on submit):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state lifted to provider):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/vitest
|
||||
@@ -1,59 +0,0 @@
|
||||
# Documentation Embeddings Generation System
|
||||
|
||||
## Overview
|
||||
|
||||
The documentation embeddings generation system processes various documentation sources and uploads their metadata to a database for semantic search functionality. The system is located in `apps/docs/scripts/search/` and works by:
|
||||
|
||||
1. **Discovering content sources** from multiple types of documentation
|
||||
2. **Processing content** into structured sections with checksums
|
||||
3. **Generating embeddings** using OpenAI's text-embedding-ada-002 model
|
||||
4. **Storing in database** with vector embeddings for semantic search
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Entry Point
|
||||
- `generate-embeddings.ts` - Main script that orchestrates the entire process
|
||||
- Supports `--refresh` flag to force regeneration of all content
|
||||
|
||||
### Content Sources (`sources/` directory)
|
||||
|
||||
#### Base Classes
|
||||
- `BaseLoader` - Abstract class for loading content from different sources
|
||||
- `BaseSource` - Abstract class for processing and formatting content
|
||||
|
||||
#### Source Types
|
||||
1. **Markdown Sources** (`markdown.ts`)
|
||||
- Processes `.mdx` files from guides and documentation
|
||||
- Extracts frontmatter metadata and content sections
|
||||
|
||||
2. **Reference Documentation** (`reference-doc.ts`)
|
||||
- **OpenAPI References** - Management API documentation from OpenAPI specs
|
||||
- **Client Library References** - JavaScript, Dart, Python, C#, Swift, Kotlin SDKs
|
||||
- **CLI References** - Command-line interface documentation
|
||||
- Processes YAML/JSON specs and matches with common sections
|
||||
|
||||
3. **GitHub Discussions** (`github-discussion.ts`)
|
||||
- Fetches troubleshooting discussions from GitHub using GraphQL API
|
||||
- Uses GitHub App authentication for access
|
||||
|
||||
4. **Partner Integrations** (`partner-integrations.ts`)
|
||||
- Fetches approved partner integration documentation from Supabase database
|
||||
- Technology integrations only (excludes agencies)
|
||||
|
||||
### Processing Flow
|
||||
|
||||
1. **Content Discovery**: Each source loader discovers and loads content files/data
|
||||
2. **Content Processing**: Each source processes content into:
|
||||
- Checksum for change detection
|
||||
- Metadata (title, subtitle, etc.)
|
||||
- Sections with headings and content
|
||||
3. **Change Detection**: Compares checksums against existing database records
|
||||
4. **Embedding Generation**: Uses OpenAI to generate embeddings for new/changed content
|
||||
5. **Database Storage**: Stores in `page` and `page_section` tables with embeddings
|
||||
6. **Cleanup**: Removes outdated pages using version tracking
|
||||
|
||||
### Database Schema
|
||||
|
||||
- **`page`** table: Stores page metadata, content, checksum, version
|
||||
- **`page_section`** table: Stores individual sections with embeddings, token counts
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
---
|
||||
description: Docs GraphQL Architecture
|
||||
globs: apps/docs/resources/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs GraphQL Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The `/apps/docs/resources` folder contains the GraphQL endpoint architecture for the docs GraphQL endpoint at `/api/graphql`. It follows a modular pattern where each top-level query is organized into its own folder with consistent file structure.
|
||||
|
||||
## Architecture Pattern
|
||||
|
||||
Each GraphQL query follows this structure:
|
||||
|
||||
```
|
||||
resources/
|
||||
├── queryObject/
|
||||
│ ├── queryObjectModel.ts # Data models and business logic
|
||||
│ ├── queryObjectSchema.ts # GraphQL type definitions
|
||||
│ ├── queryObjectResolver.ts # Query resolver and arguments
|
||||
│ ├── queryObjectTypes.ts # TypeScript interfaces (optional)
|
||||
│ └── queryObjectSync.ts # Functions for syncing repo content to the database (optional)
|
||||
├── utils/
|
||||
│ ├── connections.ts # GraphQL connection/pagination utilities
|
||||
│ └── fields.ts # GraphQL field selection utilities
|
||||
├── rootSchema.ts # Main GraphQL schema with all queries
|
||||
└── rootSync.ts # Root sync script for syncing to database
|
||||
```
|
||||
|
||||
## Example queries
|
||||
|
||||
1. **searchDocs** (`globalSearch/`) - Vector-based search across all docs content
|
||||
2. **error** (`error/`) - Error code lookup for Supabase services
|
||||
3. **schema** - GraphQL schema introspection
|
||||
|
||||
## Key Files
|
||||
|
||||
### `rootSchema.ts`
|
||||
- Main GraphQL schema definition
|
||||
- Imports all resolvers and combines them into the root query
|
||||
- Defines the `RootQueryType` with all top-level fields
|
||||
|
||||
### `utils/connections.ts`
|
||||
- Provides `createCollectionType()` for paginated collections
|
||||
- `GraphQLCollectionBuilder` for building collection responses
|
||||
- Standard pagination arguments and edge/node patterns
|
||||
|
||||
### `utils/fields.ts`
|
||||
- `graphQLFields()` utility to analyze requested fields in resolvers
|
||||
- Used for optimizing data fetching based on what fields are actually requested
|
||||
|
||||
## Creating a New Top-Level Query
|
||||
|
||||
To add a new GraphQL query, follow these steps:
|
||||
|
||||
### 1. Create Query Folder Structure
|
||||
```bash
|
||||
mkdir resources/newQuery
|
||||
touch resources/newQuery/newQueryModel.ts
|
||||
touch resources/newQuery/newQuerySchema.ts
|
||||
touch resources/newQuery/newQueryResolver.ts
|
||||
```
|
||||
|
||||
### 2. Define GraphQL Schema (`newQuerySchema.ts`)
|
||||
```typescript
|
||||
import { GraphQLObjectType, GraphQLString } from 'graphql'
|
||||
|
||||
export const GRAPHQL_FIELD_NEW_QUERY = 'newQuery' as const
|
||||
|
||||
export const GraphQLObjectTypeNewQuery = new GraphQLObjectType({
|
||||
name: 'NewQuery',
|
||||
description: 'Description of what this query returns',
|
||||
fields: {
|
||||
id: {
|
||||
type: GraphQLString,
|
||||
description: 'Unique identifier',
|
||||
},
|
||||
// Add other fields...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Create Data Model (`newQueryModel.ts`)
|
||||
|
||||
> [!NOTE]
|
||||
> The data model should be agnostic to GraphQL. It may import argument types
|
||||
> from `~/__generated__/graphql`, but otherwise all functions and classes
|
||||
> should be unaware of whether they are called for GraphQL resolution.
|
||||
|
||||
> [!TIP]
|
||||
> The types in `~/__generated__/graphql` for a new endpoint will not exist
|
||||
> until the code generation is run in the next step.
|
||||
|
||||
```typescript
|
||||
import { type RootQueryTypeNewQueryArgs } from '~/__generated__/graphql'
|
||||
import { convertPostgrestToApiError, type ApiErrorGeneric } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { supabase } from '~/lib/supabase'
|
||||
|
||||
export class NewQueryModel {
|
||||
constructor(public readonly data: {
|
||||
id: string
|
||||
// other properties...
|
||||
}) {}
|
||||
|
||||
static async loadData(
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
requestedFields: Array<string>
|
||||
): Promise<Result<NewQueryModel[], ApiErrorGeneric>> {
|
||||
// Implement data fetching logic
|
||||
const result = new Result(
|
||||
await supabase()
|
||||
.from('your_table')
|
||||
.select('*')
|
||||
// Add filters based on args
|
||||
)
|
||||
.map((data) => data.map((item) => new NewQueryModel(item)))
|
||||
.mapError(convertPostgrestToApiError)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Resolver (`newQueryResolver.ts`)
|
||||
```typescript
|
||||
import { GraphQLError, GraphQLNonNull, GraphQLString, type GraphQLResolveInfo } from 'graphql'
|
||||
import { type RootQueryTypeNewQueryArgs } from '~/__generated__/graphql'
|
||||
import { convertUnknownToApiError } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { graphQLFields } from '../utils/fields'
|
||||
import { NewQueryModel } from './newQueryModel'
|
||||
import { GRAPHQL_FIELD_NEW_QUERY, GraphQLObjectTypeNewQuery } from './newQuerySchema'
|
||||
|
||||
async function resolveNewQuery(
|
||||
_parent: unknown,
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
_context: unknown,
|
||||
info: GraphQLResolveInfo
|
||||
): Promise<NewQueryModel[] | GraphQLError> {
|
||||
return (
|
||||
await Result.tryCatchFlat(
|
||||
resolveNewQueryImpl,
|
||||
convertUnknownToApiError,
|
||||
args,
|
||||
info
|
||||
)
|
||||
).match(
|
||||
(data) => data,
|
||||
(error) => {
|
||||
console.error(`Error resolving ${GRAPHQL_FIELD_NEW_QUERY}:`, error)
|
||||
return new GraphQLError(error.isPrivate() ? 'Internal Server Error' : error.message)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveNewQueryImpl(
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
info: GraphQLResolveInfo
|
||||
): Promise<Result<NewQueryModel[], ApiErrorGeneric>> {
|
||||
const fieldsInfo = graphQLFields(info)
|
||||
const requestedFields = Object.keys(fieldsInfo)
|
||||
return await NewQueryModel.loadData(args, requestedFields)
|
||||
}
|
||||
|
||||
export const newQueryRoot = {
|
||||
[GRAPHQL_FIELD_NEW_QUERY]: {
|
||||
description: 'Description of what this query does',
|
||||
args: {
|
||||
id: {
|
||||
type: new GraphQLNonNull(GraphQLString),
|
||||
description: 'Required argument description',
|
||||
},
|
||||
// Add other arguments...
|
||||
},
|
||||
type: GraphQLObjectTypeNewQuery, // or createCollectionType() for lists
|
||||
resolve: resolveNewQuery,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Register in Root Schema
|
||||
In `rootSchema.ts`, add your resolver:
|
||||
|
||||
```typescript
|
||||
// Import your resolver
|
||||
import { newQueryRoot } from './newQuery/newQueryResolver'
|
||||
|
||||
// Add to the query fields
|
||||
export const rootGraphQLSchema = new GraphQLSchema({
|
||||
query: new GraphQLObjectType({
|
||||
name: 'RootQueryType',
|
||||
fields: {
|
||||
...introspectRoot,
|
||||
...searchRoot,
|
||||
...errorRoot,
|
||||
...newQueryRoot, // Add this line
|
||||
},
|
||||
}),
|
||||
types: [
|
||||
GraphQLObjectTypeGuide,
|
||||
GraphQLObjectTypeReferenceCLICommand,
|
||||
GraphQLObjectTypeReferenceSDKFunction,
|
||||
GraphQLObjectTypeTroubleshooting,
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### 6. Update TypeScript Types
|
||||
Run the GraphQL codegen to update TypeScript types:
|
||||
```bash
|
||||
pnpm run -F docs codegen:graphql
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling**: Error handling always uses the Result class, defined in apps/docs/features/helpers.fn.ts
|
||||
2. **Field Optimization**: Use `graphQLFields()` to only fetch requested data
|
||||
3. **Collections**: Use `createCollectionType()` for paginated lists
|
||||
4. **Naming**: Use `GRAPHQL_FIELD_*` constants for field names
|
||||
5. **Documentation**: Add GraphQL descriptions to all fields and types
|
||||
6. **Database**: Use `supabase()` client for database operations with `convertPostgrestToApiError`
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are located in apps/docs/app/api/graphql/tests. Each top-level query
|
||||
should have its own test file, located at <queryName>.test.ts.
|
||||
|
||||
### Test data
|
||||
|
||||
Test data uses a local database, seeded with the file at supabase/seed.sql. Add
|
||||
any data required for running your new query.
|
||||
|
||||
### Integration tests
|
||||
|
||||
Integration tests import the POST function defined in
|
||||
apps/docs/api/graphql/route.ts, then make a request to this function.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { POST } from '../route'
|
||||
|
||||
it('test name', async () => {
|
||||
const query = `
|
||||
query {
|
||||
...
|
||||
}
|
||||
`
|
||||
const request = new Request('http://localhost/api/graphql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
|
||||
const result = await POST(request)
|
||||
})
|
||||
```
|
||||
|
||||
Include at least the following tests:
|
||||
|
||||
1. A test that requests all fields (including nested fields) on the new query
|
||||
object, and asserts that there are no errors, and the requested fields are
|
||||
properly returned.
|
||||
2. A test that triggers and error, and asserts that a GraphQL error is properly
|
||||
returned.
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
description: Docs Testing Procedure
|
||||
globs: apps/docs/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs Test Requirements
|
||||
|
||||
Rules for running tests in the docs application, ensuring proper Supabase setup and test execution.
|
||||
|
||||
<rule>
|
||||
name: docs_test_requirements
|
||||
description: Standards for running tests in the docs application with proper Supabase setup
|
||||
filters:
|
||||
# Match test files in the docs app
|
||||
- type: file_extension
|
||||
pattern: "\\.(test|spec)\\.(ts|tsx)$"
|
||||
- type: path
|
||||
pattern: "^apps/docs/.*"
|
||||
# Match test execution events
|
||||
- type: event
|
||||
pattern: "test_execution"
|
||||
|
||||
actions:
|
||||
- type: suggest
|
||||
message: |
|
||||
Before running tests in the docs app:
|
||||
|
||||
1. Check Supabase status:
|
||||
```bash
|
||||
pnpm supabase status
|
||||
```
|
||||
|
||||
2. If Supabase is not running:
|
||||
```bash
|
||||
pnpm supabase start
|
||||
```
|
||||
|
||||
3. Reset the database to ensure clean state:
|
||||
```bash
|
||||
pnpm supabase db reset --local
|
||||
```
|
||||
|
||||
4. Run the tests:
|
||||
```bash
|
||||
pnpm run -F docs test:local:unwatch
|
||||
```
|
||||
|
||||
Important notes:
|
||||
- Always ensure Supabase is running before tests
|
||||
- Database must be reset to ensure clean state
|
||||
- Use test:local:unwatch to run tests without watch mode
|
||||
- Tests are located in apps/docs/**/*.{test,spec}.{ts,tsx}
|
||||
|
||||
examples:
|
||||
- input: |
|
||||
# Bad: Running tests without proper setup
|
||||
pnpm run -F docs test
|
||||
pnpm run -F docs test:local
|
||||
|
||||
# Good: Proper test execution sequence
|
||||
pnpm supabase status
|
||||
pnpm supabase start # if not running
|
||||
pnpm supabase db reset --local
|
||||
pnpm run -F docs test:local:unwatch
|
||||
output: "Correctly executed docs tests with proper Supabase setup"
|
||||
|
||||
metadata:
|
||||
priority: high
|
||||
version: 1.0
|
||||
</rule>
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
description: "Docs: embeddings generation pipeline (apps/docs/scripts/search)"
|
||||
globs:
|
||||
- apps/docs/scripts/search/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Documentation Embeddings Generation System
|
||||
|
||||
## Overview
|
||||
|
||||
The documentation embeddings generation system processes various documentation sources and uploads their metadata to a database for semantic search functionality. The system is located in `apps/docs/scripts/search/` and works by:
|
||||
|
||||
1. **Discovering content sources** from multiple types of documentation
|
||||
2. **Processing content** into structured sections with checksums
|
||||
3. **Generating embeddings** using OpenAI's text-embedding-ada-002 model
|
||||
4. **Storing in database** with vector embeddings for semantic search
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Entry Point
|
||||
|
||||
- `apps/docs/scripts/search/generate-embeddings.ts` - Main script that orchestrates the entire process
|
||||
- Supports `--refresh` flag to force regeneration of all content
|
||||
|
||||
### Content Sources (`sources/` directory)
|
||||
|
||||
#### Base Classes
|
||||
|
||||
- `BaseLoader` - Abstract class for loading content from different sources
|
||||
- `BaseSource` - Abstract class for processing and formatting content
|
||||
|
||||
#### Source Types
|
||||
|
||||
1. **Markdown Sources** (`apps/docs/scripts/search/sources/markdown.ts`)
|
||||
- Processes `.mdx` files from guides and documentation
|
||||
- Extracts frontmatter metadata and content sections
|
||||
|
||||
2. **Reference Documentation** (`apps/docs/scripts/search/sources/reference-doc.ts`)
|
||||
- **OpenAPI References** - Management API documentation from OpenAPI specs
|
||||
- **Client Library References** - JavaScript, Dart, Python, C#, Swift, Kotlin SDKs
|
||||
- **CLI References** - Command-line interface documentation
|
||||
- Processes YAML/JSON specs and matches with common sections
|
||||
|
||||
3. **GitHub Discussions** (`apps/docs/scripts/search/sources/github-discussion.ts`)
|
||||
- Fetches troubleshooting discussions from GitHub using GraphQL API
|
||||
- Uses GitHub App authentication for access
|
||||
|
||||
4. **Partner Integrations** (`apps/docs/scripts/search/sources/partner-integrations.ts`)
|
||||
- Fetches approved partner integration documentation from Supabase database
|
||||
- Technology integrations only (excludes agencies)
|
||||
|
||||
### Processing Flow
|
||||
|
||||
1. **Content Discovery**: Each source loader discovers and loads content files/data
|
||||
2. **Content Processing**: Each source processes content into:
|
||||
- Checksum for change detection
|
||||
- Metadata (title, subtitle, etc.)
|
||||
- Sections with headings and content
|
||||
3. **Change Detection**: Compares checksums against existing database records
|
||||
4. **Embedding Generation**: Uses OpenAI to generate embeddings for new/changed content
|
||||
5. **Database Storage**: Stores in `page` and `page_section` tables with embeddings
|
||||
6. **Cleanup**: Removes outdated pages using version tracking
|
||||
|
||||
### Database Schema
|
||||
|
||||
- **`page`** table: Stores page metadata, content, checksum, version
|
||||
- **`page_section`** table: Stores individual sections with embeddings, token counts
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
description: "Docs: GraphQL architecture for apps/docs/resources"
|
||||
globs:
|
||||
- apps/docs/resources/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs GraphQL Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The `apps/docs/resources` folder contains the GraphQL endpoint architecture for the docs GraphQL endpoint at `/api/graphql`. It follows a modular pattern where each top-level query is organized into its own folder with consistent file structure.
|
||||
|
||||
## Architecture Pattern
|
||||
|
||||
Each GraphQL query follows this structure:
|
||||
|
||||
```
|
||||
resources/
|
||||
├── queryObject/
|
||||
│ ├── queryObjectModel.ts # Data models and business logic
|
||||
│ ├── queryObjectSchema.ts # GraphQL type definitions
|
||||
│ ├── queryObjectResolver.ts # Query resolver and arguments
|
||||
│ ├── queryObjectTypes.ts # TypeScript interfaces (optional)
|
||||
│ └── queryObjectSync.ts # Functions for syncing repo content to the database (optional)
|
||||
├── utils/
|
||||
│ ├── connections.ts # GraphQL connection/pagination utilities
|
||||
│ └── fields.ts # GraphQL field selection utilities
|
||||
├── rootSchema.ts # Main GraphQL schema with all queries
|
||||
└── rootSync.ts # Root sync script for syncing to database
|
||||
```
|
||||
|
||||
## Example queries
|
||||
|
||||
1. **searchDocs** (`globalSearch/`) - Vector-based search across all docs content
|
||||
2. **error** (`error/`) - Error code lookup for Supabase services
|
||||
3. **schema** - GraphQL schema introspection
|
||||
|
||||
## Key Files
|
||||
|
||||
### `rootSchema.ts`
|
||||
|
||||
- Main GraphQL schema definition
|
||||
- Imports all resolvers and combines them into the root query
|
||||
- Defines the `RootQueryType` with all top-level fields
|
||||
|
||||
### `utils/connections.ts`
|
||||
|
||||
- Provides `createCollectionType()` for paginated collections
|
||||
- `GraphQLCollectionBuilder` for building collection responses
|
||||
- Standard pagination arguments and edge/node patterns
|
||||
|
||||
### `utils/fields.ts`
|
||||
|
||||
- `graphQLFields()` utility to analyze requested fields in resolvers
|
||||
- Used for optimizing data fetching based on what fields are actually requested
|
||||
|
||||
## Creating a New Top-Level Query
|
||||
|
||||
To add a new GraphQL query, follow these steps:
|
||||
|
||||
### 1. Create Query Folder Structure
|
||||
|
||||
```bash
|
||||
mkdir resources/newQuery
|
||||
touch resources/newQuery/newQueryModel.ts
|
||||
touch resources/newQuery/newQuerySchema.ts
|
||||
touch resources/newQuery/newQueryResolver.ts
|
||||
```
|
||||
|
||||
### 2. Define GraphQL Schema (`newQuerySchema.ts`)
|
||||
|
||||
```typescript
|
||||
import { GraphQLObjectType, GraphQLString } from 'graphql'
|
||||
|
||||
export const GRAPHQL_FIELD_NEW_QUERY = 'newQuery' as const
|
||||
|
||||
export const GraphQLObjectTypeNewQuery = new GraphQLObjectType({
|
||||
name: 'NewQuery',
|
||||
description: 'Description of what this query returns',
|
||||
fields: {
|
||||
id: {
|
||||
type: GraphQLString,
|
||||
description: 'Unique identifier',
|
||||
},
|
||||
// Add other fields...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Create Data Model (`newQueryModel.ts`)
|
||||
|
||||
> [!NOTE]
|
||||
> The data model should be agnostic to GraphQL. It may import argument types
|
||||
> from `~/__generated__/graphql`, but otherwise all functions and classes
|
||||
> should be unaware of whether they are called for GraphQL resolution.
|
||||
|
||||
> [!TIP]
|
||||
> The types in `~/__generated__/graphql` for a new endpoint will not exist
|
||||
> until the code generation is run in the next step.
|
||||
|
||||
```typescript
|
||||
import { type RootQueryTypeNewQueryArgs } from '~/__generated__/graphql'
|
||||
import { convertPostgrestToApiError, type ApiErrorGeneric } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { supabase } from '~/lib/supabase'
|
||||
|
||||
export class NewQueryModel {
|
||||
constructor(
|
||||
public readonly data: {
|
||||
id: string
|
||||
// other properties...
|
||||
}
|
||||
) {}
|
||||
|
||||
static async loadData(
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
requestedFields: Array<string>
|
||||
): Promise<Result<NewQueryModel[], ApiErrorGeneric>> {
|
||||
// Implement data fetching logic
|
||||
const result = new Result(
|
||||
await supabase()
|
||||
.from('your_table')
|
||||
.select('*')
|
||||
// Add filters based on args
|
||||
)
|
||||
.map((data) => data.map((item) => new NewQueryModel(item)))
|
||||
.mapError(convertPostgrestToApiError)
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: "Docs: how to run tests locally (Supabase setup + correct commands)"
|
||||
globs:
|
||||
- apps/docs/**/*.{test,spec}.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs test requirements
|
||||
|
||||
Before running tests for `apps/docs`, ensure local Supabase is available and the DB is in a known state.
|
||||
|
||||
## Recommended sequence
|
||||
|
||||
```bash
|
||||
pnpm supabase status
|
||||
pnpm supabase start # if not running
|
||||
pnpm supabase db reset --local
|
||||
pnpm run -F docs test:local:unwatch
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Always reset the local DB before running docs tests to avoid state leakage.
|
||||
- Prefer `test:local:unwatch` for non-watch CI-like runs.
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
---
|
||||
description: How to generate pages and interfaces in Studio, a web interface for managing Supabase projects
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
- Next.js app using pages router
|
||||
- Pages go in @apps/studio/pages
|
||||
- Project related pages go in @apps/studio/pages/projects/[ref]
|
||||
- Organization related pages go in @apps/studio/pages/org/[slug]
|
||||
- Studio specific components go in @apps/studio/components
|
||||
- Studio specific generic UI components go in @apps/studio/components/ui
|
||||
- Studio specific components related to individual pages go in @apps/studio/components/interfaces e.g. @apps/studio/components/interfaces/Auth
|
||||
- Generic helper functions go in @apps/studio/lib
|
||||
- Generic hooks go in @apps/studio/hooks
|
||||
|
||||
## Component system
|
||||
|
||||
Our primitive component system is in @packages/ui and is based off shadcn/ui components. These components can be shared across all @apps e.g. studio and docs. Do not introduce new ui components unless asked to.
|
||||
|
||||
- UI components are imported from this package across apps e.g. import { Button, Badge } from 'ui'
|
||||
- Some components have a _Shadcn_ namespace appended to component name e.g. import { Input*Shadcn* } from 'ui'
|
||||
- We should be using _Shadcn_ components where possible
|
||||
- Before composing interfaces, read @packages/ui/index.tsx file for a full list of available components
|
||||
|
||||
## Styling
|
||||
|
||||
We use Tailwind for styling.
|
||||
|
||||
- You should never use tailwind classes for colours and instead use classes we've defined ourselves
|
||||
- Backgrounds // most of the time you will not need to define a background
|
||||
- 'bg' used for main app surface background
|
||||
- 'bg-muted' for elevating content // you can use Card instead
|
||||
- 'bg-warning' for highlighting information that needs to be acted on
|
||||
- 'bg-destructive' for highlighting issues
|
||||
- Text
|
||||
- 'text-foreground' for primary text like headings
|
||||
- 'text-foreground-light' for body text
|
||||
- 'text-foreground-lighter' for subtle text
|
||||
- 'text-warning' for calling out information that needs action
|
||||
- 'text-destructive' for calling out when something went wrong
|
||||
- When needing to apply typography styles, read @apps/studio/styles/typography.scss and use one of the available classes instead of hard coding classes e.g. use "heading-default" instead of "text-sm font-medium"
|
||||
- When applying focus styles for keyboard navigation, read @apps/studio/styles/focus.scss for any appropriate classes for consistency with other focus styles
|
||||
|
||||
## Page structure
|
||||
|
||||
When creating a new page follow these steps:
|
||||
|
||||
- Create the page in @apps/studio/pages
|
||||
- Use the PageLayout component that has the following props
|
||||
|
||||
```jsx
|
||||
export interface NavigationItem {
|
||||
id?: string
|
||||
label: string
|
||||
href?: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
badge?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
interface PageLayoutProps {
|
||||
children?: ReactNode
|
||||
title?: string | ReactNode
|
||||
subtitle?: string | ReactNode
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
label?: string
|
||||
href?: string
|
||||
element?: ReactNode
|
||||
}>
|
||||
primaryActions?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
navigationItems?: NavigationItem[]
|
||||
className?: string
|
||||
size?: 'default' | 'full' | 'large' | 'small'
|
||||
isCompact?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
- If a page has page related actions, add them to primary and secondary action props e.g. Users page has "Create new user" action
|
||||
- If a page is within an existing section (e.g. Auth), you should use the related layout component e.g. AuthLayout
|
||||
- Create a new component in @apps/studio/components/interfaces for the contents of the page
|
||||
- Use ScaffoldContainer if the page should be center aligned in a container
|
||||
- Use ScaffoldSection, ScaffoldSectionTitle, ScaffoldSectionDescription if the page has multiple sections
|
||||
|
||||
### Page example
|
||||
|
||||
```jsx
|
||||
import { MyPageComponent } from 'components/interfaces/MyPage/MyPageComponent'
|
||||
import AuthLayout from './AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const MyPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<MyPageComponent />
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
MyPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default MyPage
|
||||
|
||||
export const MyPageComponent = () => (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div>
|
||||
<ScaffoldSectionTitle>My page section</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>A brief description of the purpose of the page</ScaffoldSectionDescription>
|
||||
</div>
|
||||
// Content goes here
|
||||
</ScaffoldSection>
|
||||
)
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
- Build forms with `react-hook-form` + `zod`.
|
||||
- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`).
|
||||
- Keep imports from `ui` with `_Shadcn_` suffixes.
|
||||
- Forms should generally be wrapped in a Card unless specified
|
||||
- If the submit button is outside the form, add a new variable named formId outside the component, and set it as property id on the form element and formId on the button.
|
||||
|
||||
### Example (single field)
|
||||
|
||||
```tsx
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { Button, Form_Shadcn_, FormField_Shadcn_, FormControl_Shadcn_, Input_Shadcn_ } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
const profileSchema = z.object({
|
||||
username: z.string().min(2, 'Username must be at least 2 characters'),
|
||||
})
|
||||
|
||||
const formId = `profile-form`
|
||||
|
||||
export function ProfileForm() {
|
||||
const form = useForm<z.infer<typeof profileSchema>>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: { username: '' },
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onBlur',
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof profileSchema>) {
|
||||
// handle values
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Username"
|
||||
description="This is your public display name."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ placeholder="shadcn" autoComplete="off" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="primary" htmlType="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Cards
|
||||
|
||||
- Use cards when needing to group related pieces of information
|
||||
- Cards can have sections with CardContent
|
||||
- Use CardFooter for actions
|
||||
- Only use CardHeader and CardTitle if the card content has not been described by the surrounding content e.g. Page title or ScaffoldSectionTitle
|
||||
- Use CardHeader and CardTitle when you are using multiple Cards to group related pieces of content e.g. Primary branch, Persistent branches, Preview branches
|
||||
|
||||
## Sheets
|
||||
|
||||
- Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list
|
||||
|
||||
## React Query
|
||||
|
||||
- When doing a mutation, always use the mutate function. Always use onSuccess and onError with a toast.success and toast.error.
|
||||
- Use mutateAsync only if the mutation is part of multiple async actions. Wrap the mutateAsync call with try/catch block and add toast.success and toast.error.
|
||||
|
||||
## Tables
|
||||
|
||||
- Use the generic ui table components for most tables
|
||||
- Tables are generally contained witin a card
|
||||
- If a table has associated actions, they should go above on right hand side
|
||||
- If a table has associated search or filters, they should go above on left hand side
|
||||
- If a table is the main content of a page, and it does not have search or filters, you can add table actions to primary and secondary actions of PageLayout
|
||||
- If a table is the main content of a page section, and it does not have search or filters, you can add table actions to the right of ScaffoldSectionTitle
|
||||
- For simple lists of objects you can use ResourceList with ResourceListItem instead
|
||||
|
||||
### Table example
|
||||
|
||||
```jsx
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
|
||||
;<Table>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">INV001</TableCell>
|
||||
<TableCell>Paid</TableCell>
|
||||
<TableCell>Credit Card</TableCell>
|
||||
<TableCell className="text-right">$250.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
- Use Admonition component to alert users of important actions or restrictions in place
|
||||
- Place the Admonition either at the top of the contents of the page (below page title) or at the top of the related ScaffoldSection , below ScaffoldTitle
|
||||
- Use sparingly
|
||||
|
||||
### Alert example
|
||||
|
||||
```jsx
|
||||
<Admonition
|
||||
type="note"
|
||||
title="No authentication logs available for this user"
|
||||
description="Auth events such as logging in will be shown here"
|
||||
/>
|
||||
```
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs: apps/studio/**/*.test.ts,apps/studio/**/*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
Make sure to follow the guidelines in this file to write tests: [README.md](mdc:apps/studio/tests/README.md)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/vitest
|
||||
+9
-7
@@ -1,20 +1,22 @@
|
||||
/packages/ui/ @supabase/design
|
||||
/packages/shared-data/pricing.ts @roryw10 @supabase/billing
|
||||
/packages/shared-data/plans.ts @roryw10 @supabase/billing
|
||||
/packages/shared-data/pricing.ts @supabase/billing
|
||||
/packages/shared-data/plans.ts @supabase/billing
|
||||
/packages/common/telemetry-constants.ts @supabase/growth-eng
|
||||
/packages/pg-meta @supabase/postgres @avallete
|
||||
/packages/dev-tools/ @supabase/growth-eng
|
||||
# /packages/pg-meta @supabase/postgres @avallete
|
||||
/packages/ui-patterns @supabase/design
|
||||
|
||||
/apps/studio/ @supabase/Dashboard
|
||||
|
||||
/apps/cms/ @supabase/marketing
|
||||
|
||||
/apps/www/ @supabase/marketing
|
||||
/apps/www/public/images/blog @supabase/marketing
|
||||
/apps/www/lib/redirects.js
|
||||
|
||||
/docker/ @supabase/dev-workflows @supabase/self-hosted
|
||||
/docker/ @supabase/cli @supabase/self-hosted
|
||||
|
||||
/apps/studio/csp.js @supabase/security
|
||||
/apps/studio/csp.ts @supabase/security
|
||||
/apps/studio/components/interfaces/Billing/Payment @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
|
||||
/apps/studio/pages/new/index.tsx @supabase/security
|
||||
|
||||
/packages/shared-data/compute-disk-limits.ts @supabase/infra
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copilot Code Review Instructions
|
||||
|
||||
## Review Policy — Read This First
|
||||
|
||||
You are a code reviewer for a large TypeScript/Next.js/React monorepo. Your reviews must be **low-noise and high-signal**. The team acts on fewer than 20% of default Copilot suggestions, so every comment you leave must earn its place.
|
||||
|
||||
### Confidence Threshold
|
||||
|
||||
Only comment when you are **>85% confident** the issue is a real bug, security vulnerability, or logic error. If you are unsure, do not comment. Silence is better than noise.
|
||||
|
||||
### What NOT to Comment On
|
||||
|
||||
Our CI pipeline already validates the following. **Never comment on these topics:**
|
||||
|
||||
- **Formatting or whitespace** — Prettier runs on every PR
|
||||
- **Linting issues** — ESLint with auto-fix runs on every PR
|
||||
- **Type errors** — TypeScript strict-mode typecheck runs on every PR
|
||||
- **Typos or spelling** — Automated typo detection runs on every PR
|
||||
- **Missing tests for trivial changes** — Handled by topic-specific test instructions
|
||||
- **Import ordering or grouping** — Handled by linter
|
||||
- **Naming style preferences** (camelCase vs snake_case debates) — Follow existing file conventions
|
||||
- **Accessibility attributes on shadcn/Radix UI components** — See `studio-shadcn-components.instructions.md` for details
|
||||
|
||||
### What TO Comment On (Priority Order)
|
||||
|
||||
1. **Logic errors and bugs** — Off-by-one, null derefs, wrong conditional, unreachable code, incorrect early returns
|
||||
2. **Security vulnerabilities** — XSS, SQL injection, auth bypass, secrets in code, unsafe `dangerouslySetInnerHTML`
|
||||
3. **Race conditions and async bugs** — Missing `await`, unhandled promise rejections, stale closures, effect cleanup issues
|
||||
4. **Data loss risks** — Destructive operations without confirmation, missing error handling on writes
|
||||
5. **API contract violations** — Wrong HTTP method, missing auth headers, incorrect request/response shapes
|
||||
|
||||
### Comment Style
|
||||
|
||||
- **Be advisory, not prescriptive.** Use "Consider..." or "This may..." — never demand changes.
|
||||
- **One comment per distinct issue.** Do not leave multiple comments about the same underlying problem.
|
||||
- **No self-contradictions.** If you suggest a change, do not then flag a problem with your own suggestion.
|
||||
- **Do not comment on individual commits.** Review the final state of the PR diff only.
|
||||
|
||||
## Repo Context
|
||||
|
||||
This is a TypeScript/Next.js/React monorepo:
|
||||
|
||||
- `apps/studio/` — Supabase Dashboard (primary review target)
|
||||
- `apps/www/` — Marketing site
|
||||
- `apps/docs/` — Documentation
|
||||
- `packages/common/` — Shared code including telemetry definitions
|
||||
|
||||
## Topic-Specific Guidelines
|
||||
|
||||
Path-specific rules in `.github/instructions/`:
|
||||
|
||||
- **Telemetry**: `studio-telemetry.instructions.md` — event naming, property conventions, feature flag measurement
|
||||
- **Testing**: `studio-testing.instructions.md` — test strategy, extraction patterns, coverage expectations
|
||||
- **Error Handling**: `studio-error-handling.instructions.md` — error classification, `ErrorMatcher` usage
|
||||
- **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`)
|
||||
- **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components
|
||||
- **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag
|
||||
- **Keyboard Shortcuts**: `studio-shortcuts.instructions.md` — shortcut registry pattern, search-input escape handler, when to flag missing coverage
|
||||
|
||||
These files are scoped to `apps/studio/` and applied automatically during reviews.
|
||||
@@ -4,6 +4,8 @@ updates:
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
cooldown:
|
||||
default-days: 7
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# React Composition Patterns Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Avoid boolean prop proliferation. Use composition (compound components, explicit variants, children) instead of boolean flags to customize behavior.
|
||||
|
||||
## When to Flag
|
||||
|
||||
### 1. Boolean Prop Proliferation (HIGH)
|
||||
|
||||
Flag components accumulating boolean props like `isThread`, `isEditing`, `showAttachments`. Each boolean doubles the state space.
|
||||
|
||||
```tsx
|
||||
// BAD — unclear intent, combinatorial explosion
|
||||
<Composer isThread isDMThread isEditing isForwarding={false} />
|
||||
|
||||
// GOOD — self-documenting variants
|
||||
<ThreadComposer channelId="abc" />
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
```
|
||||
|
||||
### 2. Render Props Instead of Children (MEDIUM)
|
||||
|
||||
Flag `renderX` callback props when `children` composition would work.
|
||||
|
||||
```tsx
|
||||
// BAD — render prop for structure
|
||||
<Composer renderFooter={() => <F />} />
|
||||
|
||||
// GOOD — compound component
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
```
|
||||
|
||||
### 3. UI Coupled to State Implementation (MEDIUM)
|
||||
|
||||
Flag UI components calling specific state hooks like `useGlobalChannelState()` directly. The provider should own the state implementation; UI should only use a generic context interface.
|
||||
|
||||
```tsx
|
||||
// BAD — UI knows HOW state is managed
|
||||
const state = useGlobalChannelState(channelId)
|
||||
|
||||
// GOOD — provider owns implementation, UI uses context
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<Composer /> {/* reads from context */}
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
### 4. State Trapped in Child Components (MEDIUM)
|
||||
|
||||
Flag state that siblings or dialogs need but can't access without prop drilling or refs. Lift it into a provider.
|
||||
|
||||
```tsx
|
||||
// BAD — sibling can't access state
|
||||
function ForwardComposer() {
|
||||
const [state, setState] = useState(init)
|
||||
}
|
||||
// ForwardButton is a sibling and can't reach state
|
||||
|
||||
// GOOD — provider at shared ancestor
|
||||
<ForwardMessageProvider>
|
||||
<Composer /> {/* can access state */}
|
||||
<ForwardButton /> {/* can also access state */}
|
||||
</ForwardMessageProvider>
|
||||
```
|
||||
|
||||
### 5. React 19 API Updates
|
||||
|
||||
Flag `forwardRef` and `useContext` in new code — use `ref` as a regular prop and `use()` instead.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
const Input = forwardRef((props, ref) => <input ref={ref} />)
|
||||
const value = useContext(MyContext)
|
||||
|
||||
// GOOD
|
||||
function Input({ ref, ...props }) { return <input ref={ref} /> }
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
## Key Principle
|
||||
|
||||
Lift state → Compose UI → Inject via generic context → No boolean prop proliferation.
|
||||
|
||||
Canonical standard: `.claude/skills/vercel-composition-patterns/SKILL.md`
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
applyTo: 'e2e/studio/**,apps/studio/**'
|
||||
---
|
||||
|
||||
# Studio E2E Test Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Selector Priority (best to worst)
|
||||
|
||||
1. **`getByRole` with accessible name** — most robust, tests accessibility
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
```
|
||||
|
||||
2. **`getByTestId`** — stable, explicit test hooks
|
||||
|
||||
```typescript
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
```
|
||||
|
||||
3. **`getByText` with exact match** — good for unique text
|
||||
|
||||
```typescript
|
||||
page.getByText('Data API Access', { exact: true })
|
||||
```
|
||||
|
||||
4. **`locator` with CSS** — use sparingly, more fragile
|
||||
```typescript
|
||||
page.locator('[data-state="open"]')
|
||||
```
|
||||
|
||||
## Patterns to Flag
|
||||
|
||||
- **XPath selectors** — fragile to DOM changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
locator('xpath=ancestor::div[contains(@class, "space-y")]')
|
||||
```
|
||||
|
||||
- **Parent traversal with `locator('..')`** — breaks when structure changes
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
element.locator('..').getByRole('button')
|
||||
```
|
||||
|
||||
- **`waitForTimeout`** — never use; wait for something specific instead
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// GOOD — wait for UI element
|
||||
await expect(page.getByText('Success')).toBeVisible()
|
||||
|
||||
// GOOD — wait for API response
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await saveButton.click()
|
||||
await apiPromise
|
||||
```
|
||||
|
||||
- **`force: true` on clicks** — make elements visible first instead
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
|
||||
// GOOD — hover to reveal, then click
|
||||
await tableRow.hover()
|
||||
await expect(menuButton).toBeVisible()
|
||||
await menuButton.click()
|
||||
```
|
||||
|
||||
- **Broad `filter({ hasText })` on generic elements** — may match multiple elements; scope to specific containers instead
|
||||
|
||||
## Good Practices to Encourage
|
||||
|
||||
- Scope selectors to containers: `page.getByTestId('side-panel').getByRole('switch')`
|
||||
- Add `aria-label` to icon-only buttons in source code for better test selectors
|
||||
- Use `test.describe.configure({ mode: 'serial' })` for tests sharing database state
|
||||
- Add messages to expects: `await expect(locator, 'why').toBeVisible({ timeout: 30000 })`
|
||||
|
||||
Canonical standard: `.claude/skills/studio-e2e-tests/SKILL.md`
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# Studio Error Handling Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Errors flow: `handleError()` → throws typed subclass → React Query catches → `ErrorMatcher` reads `errorType` → renders troubleshooting. The component does an O(1) lookup — it never does regex matching.
|
||||
|
||||
## When to Flag
|
||||
|
||||
- PR passes `error.message` instead of the full `error` object to `ErrorMatcher` — the class type is lost
|
||||
- PR puts regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`
|
||||
- PR uses `Object.assign` to stamp `errorType` on an error — should throw a proper subclass instead
|
||||
- PR passes a raw URL string for support links — should use `supportFormParams={{ projectRef }}`
|
||||
- PR puts the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller
|
||||
- PR adds callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead
|
||||
|
||||
## Correct Usage
|
||||
|
||||
```tsx
|
||||
{isError && (
|
||||
<ErrorMatcher
|
||||
title="Failed to load tables"
|
||||
error={error}
|
||||
supportFormParams={{ projectRef }}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `data/error-patterns.ts` | `{ pattern, ErrorClass }` array — regex lives here |
|
||||
| `types/api-errors.ts` | Error classes, `KnownErrorType` union |
|
||||
| `ErrorMatcher.tsx` | Reads `errorType`, looks up mapping, renders |
|
||||
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting }>` |
|
||||
|
||||
Canonical standard: `.claude/skills/studio-error-handling/SKILL.md`
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# shadcn/Radix UI Component Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
This project uses **shadcn/ui** components built on **Radix UI** primitives (from `packages/ui/`). These components provide comprehensive accessibility out-of-the-box. **Do not flag missing accessibility attributes that are already handled by the underlying Radix primitives.**
|
||||
|
||||
## Components with Built-In Accessibility — Do NOT Flag
|
||||
|
||||
The following components (imported from `ui`) already handle ARIA roles, keyboard navigation, focus management, and screen reader support automatically via Radix UI primitives:
|
||||
|
||||
| Component | What Radix Handles |
|
||||
|-----------|-------------------|
|
||||
| `Dialog`, `AlertDialog` | `role="dialog"`, `aria-modal`, focus trapping, ESC to close |
|
||||
| `DropdownMenu`, `ContextMenu` | `role="menu"` / `role="menuitem"`, arrow key navigation |
|
||||
| `Select` | `role="combobox"`, `aria-expanded`, keyboard selection |
|
||||
| `Tabs` | `role="tablist"` / `role="tab"` / `role="tabpanel"`, `aria-selected`, arrow keys |
|
||||
| `Checkbox` | `role="checkbox"`, `aria-checked`, Space to toggle |
|
||||
| `RadioGroup` | `role="radio"`, `aria-checked`, arrow key navigation |
|
||||
| `Switch` | `role="switch"`, `aria-checked`, keyboard toggle |
|
||||
| `Tooltip` | Trigger/content association, show/hide timing |
|
||||
| `Accordion`, `Collapsible` | `aria-expanded`, Enter/Space to toggle |
|
||||
| `Popover`, `HoverCard` | Focus management, dismiss on ESC |
|
||||
| `Slider` | `role="slider"`, `aria-valuemin/max/now`, arrow keys |
|
||||
| `Toggle`, `ToggleGroup` | `aria-pressed`, keyboard support |
|
||||
| `ScrollArea` | Accessible scrollbar replacement |
|
||||
| `NavigationMenu` | `role="navigation"`, keyboard navigation |
|
||||
|
||||
### Specifically, Never Flag These
|
||||
|
||||
- Missing `role` on `Dialog`, `AlertDialog`, `DropdownMenu`, `Select`, `Tabs`, `RadioGroup`, or other Radix-based components — roles are set by the primitive
|
||||
- Missing `aria-modal` on `Dialog` or `AlertDialog` — set automatically
|
||||
- Missing `aria-expanded` on `Accordion`, `Collapsible`, `Select`, or `DropdownMenu` triggers — managed by Radix state
|
||||
- Missing `aria-selected` on `Tabs` — managed by `TabsPrimitive`
|
||||
- Missing `aria-checked` on `Checkbox`, `RadioGroup`, or `Switch` — managed by Radix state
|
||||
- Missing keyboard event handlers (`onKeyDown`, `onKeyUp`) on interactive Radix components — keyboard support is built-in
|
||||
- Missing focus management in `Dialog` or `AlertDialog` — focus trapping is automatic
|
||||
- Missing `aria-label` on `DialogClose` or `AlertDialogCancel` — these render a visible `<span className="sr-only">Close</span>`
|
||||
|
||||
## What TO Flag
|
||||
|
||||
Only flag accessibility issues for:
|
||||
|
||||
1. **Custom interactive elements** not using Radix primitives (e.g., a `<div onClick>` that should be a `<button>`)
|
||||
2. **Icon-only buttons** missing an accessible label — `<Button>` alone does not add one; use `aria-label` or `<span className="sr-only">`
|
||||
3. **Missing `Label` association** — form inputs should be paired with `<Label htmlFor="...">` or wrapped in a `<Field>` component
|
||||
4. **Images missing `alt` text** — not handled by any component library
|
||||
5. **Color-only state indicators** — state changes should not rely solely on color
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
applyTo: 'apps/studio/**'
|
||||
---
|
||||
|
||||
# Studio Shortcut Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
When Studio UI changes introduce or materially alter repeated user actions, consider whether keyboard shortcut coverage should be added or updated. Shortcuts should use the shared Studio shortcut system and be discoverable from the visible UI.
|
||||
|
||||
## When to Flag
|
||||
|
||||
- PR adds a primary repeated action, toolbar action, list/table operation, or sub-page navigation without considering shortcut coverage.
|
||||
- PR adds a one-off `keydown` listener for a normal Studio action instead of using the shortcut registry and `useShortcut`.
|
||||
- PR registers a shortcut but does not expose it via `ShortcutTooltip`, `ShortcutBadge`, or command-menu badge where the action is visible.
|
||||
- PR uses `G then ...` for a non-navigation action.
|
||||
- PR adds a broad `Mod+letter` shortcut that overlaps common browser, editor, system, copy/save/search, or devtools behaviour.
|
||||
- PR adds a shortcut without checking existing registry and non-registry listeners for collisions.
|
||||
- PR adds a search/filter `<Input>` without `onKeyDown={onSearchInputEscape(...)}` — see **Search Inputs** below.
|
||||
|
||||
## Preferred Pattern
|
||||
|
||||
- Add definitions in `apps/studio/state/shortcuts/registry.ts` or `apps/studio/state/shortcuts/registry/*`.
|
||||
- Register with `useShortcut`.
|
||||
- Gate availability with `enabled`.
|
||||
- Surface visible actions with `ShortcutTooltip` or `ShortcutBadge`.
|
||||
- Prefer scoped, mnemonic sequential chords over global modifier chords.
|
||||
- Set `showInSettings: false` on contextual shortcuts (scoped to a specific page state, sheet, or panel).
|
||||
- When a shortcut group should appear in the reference sheet (`Mod+/`), add the group key to `SHORTCUT_REFERENCE_GROUP_ORDER` in `apps/studio/state/shortcuts/referenceGroups.ts` and a human label to `GROUP_LABELS` in `ShortcutsReferenceSheet.tsx`.
|
||||
- For sheet-scoped shortcuts (active only while a `<Sheet>` is open), mount `useShortcut` inside the sheet component gated by the `open` prop — see `apps/studio/components/interfaces/ConnectSheet/useConnectSheetShortcut.ts` as the canonical example.
|
||||
|
||||
## Search Inputs
|
||||
|
||||
Every `<Input>` used as a search or filter field must include the staged-Escape handler from `apps/studio/lib/keyboard.ts`:
|
||||
|
||||
```tsx
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
|
||||
;<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(query, setQuery)}
|
||||
/>
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Escape while the input has a value** → clears the value, keeps focus (so a second Escape then blurs)
|
||||
- **Escape while the input is empty** → blurs the input
|
||||
- Stops propagation on Escape so the keystroke does not accidentally close a parent dialog or sheet
|
||||
|
||||
When pairing with `useShortcut(LIST_PAGE_FOCUS_SEARCH, ...)` to focus a search input via keyboard, always also add `onSearchInputEscape` on the same input — focus and escape-to-blur are always a pair.
|
||||
|
||||
Canonical implementation context: `apps/studio/state/shortcuts/registry.ts`, `apps/studio/state/shortcuts/useShortcut.tsx`, and `apps/studio/components/ui/Shortcut*.tsx`
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
applyTo: 'apps/studio/**,packages/common/telemetry*'
|
||||
---
|
||||
|
||||
# Studio Telemetry Review Rules
|
||||
|
||||
All comments are **advisory** — suggest, do not request changes.
|
||||
|
||||
## When to Flag Missing Telemetry
|
||||
|
||||
Use judgment — not every PR needs telemetry. But **always flag** when:
|
||||
|
||||
1. **Changes to `packages/common/telemetry-constants.ts`** — validate event naming, property conventions, and JSDoc accuracy.
|
||||
2. **PostHog feature flags without measurement.** If a PR uses `usePHFlag` or PostHog-backed hooks like `useDataApiRevokeOnCreateDefaultEnabled` to gate behavior, the flag state should be captured in a telemetry event so the rollout can be measured. Flag if the flag value isn't included in a relevant `track()` call. (Note: `useFlag` from `common` reads ConfigCat flags, not PostHog — different system, different guidance.)
|
||||
3. **Feature-flagged rollouts without outcome tracking.** If a flag gates new behavior, there should be telemetry on both the flag state _and_ how users respond to the new behavior (e.g., toggle clicks, opt-in actions).
|
||||
4. **Growth-oriented components adding user interactions without tracking** — onboarding flows, setup wizards, upgrade CTAs, A/B experiment variants.
|
||||
|
||||
When tracking is missing, comment: _"This adds a user interaction (or feature flag) that may benefit from tracking."_ Then propose an event name and `useTrack()` call.
|
||||
|
||||
## Feature Flag Telemetry Pattern
|
||||
|
||||
When capturing a PostHog flag value for telemetry, read the raw flag via `usePHFlag('flagName')` — **not** through wrapper hooks that coerce `undefined` to `false`. Use conditional spread so the property is omitted (not false) when the flag store hasn't loaded:
|
||||
|
||||
```typescript
|
||||
const flagValue = usePHFlag<boolean>('myBooleanFlag') // for boolean flags
|
||||
track('event_name', {
|
||||
...(flagValue !== undefined && { myFlagEnabled: flagValue }),
|
||||
})
|
||||
```
|
||||
|
||||
For string-valued flags (e.g., experiment variants), use `usePHFlag<string>('flagName')` instead.
|
||||
|
||||
## Event Naming
|
||||
|
||||
Format: `[object]_[verb]` in snake_case.
|
||||
|
||||
Prefer verbs already in use in `packages/common/telemetry-constants.ts`: `opened`, `clicked`, `submitted`, `created`, `removed`, `updated`, `intended`, `evaluated`, `added`, `enabled`, `disabled`, `copied`, `exposed`, `failed`, `converted`, `closed`, `completed`, `applied`, `sent`, `moved`.
|
||||
|
||||
Flag: unapproved verbs (`saved`, `viewed`, `pressed`), wrong order (`click_product_card`), wrong casing (`productCardClicked`), passive view tracking on page load (exception: `_exposed` events for A/B experiments).
|
||||
|
||||
## Event Properties
|
||||
|
||||
- **camelCase** for new events; match existing convention when extending
|
||||
- Self-explanatory names — flag generic (`label`, `value`, `name`, `data`)
|
||||
- Check `telemetry-constants.ts` for consistency with similar events
|
||||
- Never track PII
|
||||
|
||||
## Event Implementation
|
||||
|
||||
- Use `useTrack` from `lib/telemetry/track` — avoid introducing new `useSendEventMutation` usage
|
||||
- New events need a TypeScript interface in `telemetry-constants.ts` with `@group Events` and `@source` JSDoc tags (add `@page` when applicable for page-specific events), added to the `TelemetryEvent` union
|
||||
|
||||
```typescript
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
|
||||
const track = useTrack()
|
||||
track('product_card_clicked', { productType: 'database', planTier: 'pro' })
|
||||
```
|
||||
|
||||
Canonical standards: `.claude/skills/telemetry-standards/SKILL.md`
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
applyTo: "apps/studio/**"
|
||||
---
|
||||
|
||||
# Studio Testing Review Rules
|
||||
|
||||
All comments are **advisory**.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Push logic out of React components into pure `.utils.ts` functions, then test those functions exhaustively. Only use component tests for complex UI interactions.
|
||||
|
||||
## When to Comment
|
||||
|
||||
- PR adds **business logic inline in a component** that could be extracted to a `ComponentName.utils.ts` file next to the component and unit tested at `tests/components/.../ComponentName.utils.test.ts`
|
||||
- PR adds a **utility function without test coverage**
|
||||
- PR uses **component tests for pure logic** that should be a unit test on a pure function
|
||||
- PR adds a **feature used in both self-hosted and platform** without E2E test consideration
|
||||
|
||||
## Which Test Type to Suggest
|
||||
|
||||
- **Pure transformation** (parse, format, validate, compute) → extract to `.utils.ts` + unit test with vitest
|
||||
- **Complex UI interaction** → component test with `customRender` (or E2E if shared with self-hosted)
|
||||
- **E2E tests** should cover both click interactions AND keyboard shortcuts
|
||||
- **No tests at all** for non-trivial changes → nudge to add coverage
|
||||
|
||||
## Reference
|
||||
|
||||
See `.claude/skills/studio-testing/SKILL.md` for the full testing standard.
|
||||
+12
-2
@@ -1,5 +1,15 @@
|
||||
# Add 'documentation' to any change in apps/docs
|
||||
# https://github.com/marketplace/actions/labeler
|
||||
documentation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'apps/docs/**/*'
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'apps/docs/**/*'
|
||||
|
||||
# Add 'self-hosted' to any change in the self-hosting docs
|
||||
self-hosted:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'apps/docs/content/guides/self-hosting/**/*'
|
||||
|
||||
# Add 'api-deploy-required' to any change in packages/api-types/types
|
||||
api-deploy-required:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'packages/api-types/types/**'
|
||||
@@ -34,8 +34,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
packages
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Authorize Vercel Deploys
|
||||
|
||||
# This workflow is triggered by the validate-pr workflow. When it's triggered, it will run the
|
||||
# authorize-vercel-deploys.yml in master branch. If you want to change it, you'll have to merge it into master.
|
||||
on:
|
||||
# only run this workflow when the validate-pr workflow completes (successfully or not)
|
||||
workflow_run:
|
||||
workflows: ['Validate pull request']
|
||||
types: [completed]
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
authorize-vercel-deploys:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
# Checkout the master branch from the supabase repo and run that script to authorize Vercel deploys
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
# fetch only the root files and scripts folder
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Download dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Authorize Vercel Deploys
|
||||
run: |-
|
||||
pnpm run authorize-vercel-deploys
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
# The SHA of the commit that triggered the validate-pr workflow
|
||||
HEAD_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -5,32 +5,36 @@ on:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
format:
|
||||
name: Format PR without write credentials
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
if: ${{ github.event_name == 'pull_request' && (github.event.label.name == 'autofix') }}
|
||||
outputs:
|
||||
changed: ${{ steps.create-patch.outputs.changed }}
|
||||
|
||||
steps:
|
||||
- name: Calculate number of commits
|
||||
run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_ENV}"
|
||||
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.PAT_AUTOFIX }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
fetch-depth: ${{ env.PR_FETCH_DEPTH }}
|
||||
sparse-checkout: |
|
||||
packages
|
||||
apps
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -46,9 +50,69 @@ jobs:
|
||||
- name: Run Prettier in fix mode
|
||||
run: pnpm run format
|
||||
|
||||
- name: Commit changes and push to existing branch
|
||||
uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0
|
||||
- name: Create autofix patch
|
||||
id: create-patch
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --binary > autofix.patch
|
||||
|
||||
- name: Upload autofix patch
|
||||
if: steps.create-patch.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
commit_message: 'ci: Autofix updates from GitHub workflow'
|
||||
commit_user_name: 'kevcodez'
|
||||
commit_user_email: 'k.grueneberg1994@gmail.com'
|
||||
name: autofix-linters-patch
|
||||
path: autofix.patch
|
||||
retention-days: 1
|
||||
|
||||
push:
|
||||
name: Commit autofix updates
|
||||
needs: format
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
if: >-
|
||||
${{
|
||||
needs.format.outputs.changed == 'true' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
}}
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
packages
|
||||
apps
|
||||
patches
|
||||
|
||||
- name: Download autofix patch
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: autofix-linters-patch
|
||||
|
||||
- name: Commit changes and push to existing branch
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TARGET_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
git apply --index autofix.patch
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config --local user.name "supabase-autofix-bot"
|
||||
git config --local user.email "noreply@supabase.com"
|
||||
git commit -m "ci: Autofix updates from GitHub workflow"
|
||||
git -c credential.helper= push "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${TARGET_BRANCH}"
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code.
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: misspell
|
||||
uses: reviewdog/action-misspell@9daa94af4357dddb6fd3775de806bc0a8e98d3e4 # v1.26.3
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Run Braintrust evals
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
name: Run evals
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-evals') && github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
BRAINTRUST_PROJECT_ID: ${{ secrets.BRAINTRUST_PROJECT_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# For PR events, checkout the actual branch so Braintrust can report the correct branch name instead of detached HEAD.
|
||||
# github.head_ref is the PR source branch, github.ref_name is the fallback for push events (e.g., master).
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Evals
|
||||
run: cd apps/studio && pnpm evals:setup
|
||||
|
||||
- name: Run Evals
|
||||
uses: braintrustdata/eval-action@c0dd75b29984a0cc63a827d6e8da2f23f2752be4 # v1.0.16
|
||||
with:
|
||||
api_key: ${{ secrets.BRAINTRUST_API_KEY }}
|
||||
runtime: node
|
||||
package_manager: pnpm
|
||||
root: apps/studio
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Cleanup Braintrust preview scorers
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cleanup-scorers:
|
||||
name: Delete preview scorers
|
||||
if: contains(github.event.pull_request.labels.*.name, 'preview-scorers') && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Delete preview scorers from staging
|
||||
env:
|
||||
BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}
|
||||
BRAINTRUST_STAGING_PROJECT_ID: ${{ secrets.BRAINTRUST_STAGING_PROJECT_ID }}
|
||||
run: |
|
||||
BRANCH_SLUG=$(echo "${GITHUB_HEAD_REF}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g')
|
||||
readarray -t SLUGS < <(jq -r '.[].slug' apps/studio/evals/scorer-online-manifest.json)
|
||||
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
prefixed="${BRANCH_SLUG}-${slug}"
|
||||
id=$(curl -s "https://api.braintrust.dev/v1/function?project_id=${BRAINTRUST_STAGING_PROJECT_ID}&slug=${prefixed}" \
|
||||
-H "Authorization: Bearer ${BRAINTRUST_API_KEY}" | jq -r '.objects[0].id // empty')
|
||||
if [ -n "$id" ]; then
|
||||
curl -s -X DELETE "https://api.braintrust.dev/v1/function/${id}" \
|
||||
-H "Authorization: Bearer ${BRAINTRUST_API_KEY}"
|
||||
echo "Deleted ${prefixed}"
|
||||
else
|
||||
echo "Not found: ${prefixed} (already deleted or never deployed)"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Post cleanup comment
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number
|
||||
const marker = '<!-- preview-scorers-bot -->'
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
})
|
||||
|
||||
const existing = comments.find(c => c.body?.includes(marker))
|
||||
if (!existing) return
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: `${marker}\n### Braintrust Preview Scorers\n\nPreview scorers have been cleaned up.`,
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Deploy Braintrust preview scorers
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push-scorers:
|
||||
name: Push preview scorers
|
||||
if: contains(github.event.pull_request.labels.*.name, 'preview-scorers') && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
|
||||
- name: Check for scorer file changes
|
||||
id: changed
|
||||
# On labeled events, always push. On synchronize, only push if scorer files changed.
|
||||
env:
|
||||
GH_EVENT_ACTION: ${{ github.event.action }}
|
||||
GH_EVENT_PR_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
if [[ "${GH_EVENT_ACTION}" == "synchronize" ]]; then
|
||||
changed=$(git diff --name-only origin/${GH_EVENT_PR_REF}...HEAD | grep -E 'evals/scorer' || true)
|
||||
if [ -z "$changed" ]; then
|
||||
echo "No scorer files changed, skipping push"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changed.outputs.skip != 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
if: steps.changed.outputs.skip != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
if: steps.changed.outputs.skip != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Push scorers to staging
|
||||
if: steps.changed.outputs.skip != 'true'
|
||||
id: push
|
||||
run: |
|
||||
cd apps/studio && pnpm scorers:deploy
|
||||
slugs=$(jq -r '[.[].slug] | join(",")' evals/scorer-online-manifest.json)
|
||||
echo "slugs=$slugs" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}
|
||||
BRAINTRUST_PROJECT_ID: ${{ secrets.BRAINTRUST_STAGING_PROJECT_ID }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.changed.outputs.skip != 'true'
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
BRAINTRUST_STAGING_PROJECT_ID: ${{ secrets.BRAINTRUST_STAGING_PROJECT_ID }}
|
||||
SCORER_SLUGS: ${{ steps.push.outputs.slugs }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number
|
||||
const branch = process.env.GITHUB_HEAD_REF
|
||||
const prefix = branch.replace(/[^a-z0-9-]/gi, '-').toLowerCase()
|
||||
const stagingUrl = 'https://www.braintrust.dev/app/supabase.io/p/Assistant%20(Staging%20Scorers)/scorers'
|
||||
|
||||
const slugs = process.env.SCORER_SLUGS.split(',')
|
||||
const slugList = slugs.map(s => `- \`${prefix}-${s}\``).join('\n')
|
||||
|
||||
const sha = context.sha.slice(0, 7)
|
||||
const marker = '<!-- preview-scorers-bot -->'
|
||||
const body = `${marker}
|
||||
### Braintrust Preview Scorers
|
||||
|
||||
Deployed scorers to [Assistant (Staging Scorers)](${stagingUrl}):
|
||||
|
||||
${slugList}
|
||||
|
||||
Commit: ${sha}`
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
})
|
||||
|
||||
const existing = comments.find(c => c.body?.includes(marker))
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
})
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy Braintrust scorers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "apps/studio/evals/scorer.ts"
|
||||
- "apps/studio/evals/scorer-online.ts"
|
||||
- "apps/studio/evals/scorer-online-manifest.json"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push scorers
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}
|
||||
BRAINTRUST_PROJECT_ID: ${{ secrets.BRAINTRUST_PROJECT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Push scorers
|
||||
run: cd apps/studio && pnpm scorers:deploy
|
||||
@@ -18,29 +18,28 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Find Dashboard PRs older than 24 hours
|
||||
id: find-prs
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js');
|
||||
return await findStalePRs({ github, context, core });
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
patches
|
||||
|
||||
- name: Send Slack notification
|
||||
if: fromJSON(steps.find-prs.outputs.count) > 0
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Find stale Dashboard PRs and notify Slack
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }}
|
||||
STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }}
|
||||
with:
|
||||
script: |
|
||||
const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js');
|
||||
const stalePRs = JSON.parse(process.env.STALE_PRS_JSON);
|
||||
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
|
||||
await sendSlackNotification(stalePRs, webhookUrl);
|
||||
|
||||
- name: No stale PRs found
|
||||
if: fromJSON(steps.find-prs.outputs.count) == 0
|
||||
run: |
|
||||
echo "✓ No Dashboard PRs older than 24 hours found"
|
||||
run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts
|
||||
@@ -19,11 +19,12 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
@@ -42,33 +43,44 @@ jobs:
|
||||
|
||||
- name: Regenerate JS client libraries tsdoc files
|
||||
working-directory: apps/docs/spec
|
||||
env:
|
||||
SOURCE: ${{ github.event.inputs.source }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
echo "Regenerating tsdoc files for JS client libraries..."
|
||||
echo "Source: ${{ github.event.inputs.source }}"
|
||||
echo "Version: ${{ github.event.inputs.version }}"
|
||||
make
|
||||
echo "Source: ${SOURCE}"
|
||||
echo "Version: ${VERSION}"
|
||||
make download.tsdoc.v2
|
||||
|
||||
- name: Generate new typespec snapshot
|
||||
- name: Refresh reference-content snapshot
|
||||
working-directory: apps/docs
|
||||
run: |
|
||||
echo "Generating new typespec snapshot for review..."
|
||||
npx vitest run --update ./features/docs/Reference.typeSpec.test.ts
|
||||
# Using --dir (not a file path) so Vitest 4 doesn't run unrelated tests.
|
||||
run: npx vitest run --update --dir scripts
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'docs: update js client libraries (${{ github.event.inputs.version }})'
|
||||
title: 'docs: update js client libraries (${{ github.event.inputs.version }})'
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: 'docs: update js sdk docs (${{ github.event.inputs.version }})'
|
||||
title: 'docs: update js sdk docs (${{ github.event.inputs.version }})'
|
||||
body: |
|
||||
Updates JS client libraries documentation following stable release.
|
||||
Ran `make` in apps/docs/spec to regenerate tsdoc files.
|
||||
Updates JS sdk documentation following stable release.
|
||||
Ran `make download.tsdoc.v2` in apps/docs/spec and refreshed the reference-content snapshot.
|
||||
|
||||
**Details:**
|
||||
- **Version:** `${{ github.event.inputs.version }}`
|
||||
- **Source:** `${{ github.event.inputs.source }}`
|
||||
- **Changes:** Regenerated tsdoc files from latest spec files
|
||||
|
||||
🤖 Auto-generated from supabase-js-libs stable release.
|
||||
branch: 'gha/update-js-libs-docs-${{ github.run_number }}'
|
||||
🤖 Auto-generated from @supabase/supabase-js stable release.
|
||||
branch: 'gha/update-js-sdk-docs-${{ github.run_number }}'
|
||||
base: 'master'
|
||||
@@ -26,8 +26,10 @@ jobs:
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
sparse-checkout: |
|
||||
supa-mdx-lint.config.toml
|
||||
supa-mdx-lint
|
||||
@@ -31,10 +32,10 @@ jobs:
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: 301e0d4b35f8f0c8553b4e93917b8b2685ef2627
|
||||
key: 6b08233ff8bca855f6a38246b2a8049332219188
|
||||
- name: install linter
|
||||
if: steps.cache-cargo.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 301e0d4b35f8f0c8553b4e93917b8b2685ef2627
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 6b08233ff8bca855f6a38246b2a8049332219188
|
||||
- name: run linter
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
supa-mdx-lint.config.toml
|
||||
supa-mdx-lint
|
||||
@@ -46,17 +47,17 @@ jobs:
|
||||
- name: cache cargo
|
||||
id: cache-cargo
|
||||
if: steps.filter.outputs.docs == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: 301e0d4b35f8f0c8553b4e93917b8b2685ef2627
|
||||
key: 6b08233ff8bca855f6a38246b2a8049332219188
|
||||
- name: install linter
|
||||
if: steps.filter.outputs.docs == 'true' && steps.cache-cargo.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 301e0d4b35f8f0c8553b4e93917b8b2685ef2627
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 6b08233ff8bca855f6a38246b2a8049332219188
|
||||
- name: install reviewdog
|
||||
if: steps.filter.outputs.docs == 'true'
|
||||
uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0
|
||||
|
||||
@@ -17,9 +17,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
patches
|
||||
packages/generator
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -39,10 +42,19 @@ jobs:
|
||||
working-directory: apps/docs/spec
|
||||
run: make download.api.v1 dereference.api.v1 generate.sections.api.v1 format
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-pull-requests: write
|
||||
permission-contents: write
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: 'feat: update mgmt api docs'
|
||||
title: 'feat: update mgmt api docs'
|
||||
body: 'This PR updates mgmt api docs automatically.'
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Install pnpm
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
@@ -35,19 +35,12 @@ jobs:
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Decode the GitHub App Private Key
|
||||
id: decode
|
||||
run: |
|
||||
private_key=$(echo "${{ secrets.DOCS_GITHUB_APP_PRIVATE_KEY }}" | base64 --decode | awk 'BEGIN {ORS="\\n"} {print}' | head -c -2) &> /dev/null
|
||||
echo "::add-mask::$private_key"
|
||||
echo "private-key=$private_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub App token for supabase/troubleshooting
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.DOCS_GITHUB_APP_ID }}
|
||||
private-key: ${{ steps.decode.outputs.private-key }}
|
||||
client-id: ${{ vars.DOCS_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.DOCS_GITHUB_APP_PRIVATE_KEY_V2 }}
|
||||
repositories: troubleshooting
|
||||
permission-contents: read
|
||||
|
||||
@@ -59,9 +52,17 @@ jobs:
|
||||
repository: supabase/troubleshooting
|
||||
path: troubleshooting-upstream
|
||||
|
||||
- name: Generate PR token
|
||||
id: pr-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Sync supabase/troubleshooting changes back to supabase/supabase
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.pr-token.outputs.token }}
|
||||
run: |
|
||||
git config user.name 'github-docs-bot'
|
||||
git config user.email 'github-docs-bot@supabase.com'
|
||||
|
||||
@@ -26,8 +26,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -54,3 +56,4 @@ jobs:
|
||||
author: 'github-docs-sync-bot <github-docs-sync-bot@supabase.com>'
|
||||
branch: 'bot/docs-sync-troubleshooting'
|
||||
branch-suffix: 'random'
|
||||
labels: 'documentation'
|
||||
@@ -28,9 +28,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -21,9 +21,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -25,11 +25,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
examples
|
||||
packages
|
||||
supabase
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -45,6 +47,14 @@ jobs:
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download JS reference TypeDoc dumps
|
||||
# The source dumps under apps/docs/spec/reference/<lib>/<ver>/*.json are
|
||||
# gitignored — `make download.tsdoc.v2` re-fetches them from
|
||||
# supabase.github.io so the reference-content snapshot test has
|
||||
# something to walk.
|
||||
working-directory: apps/docs/spec
|
||||
run: make download.tsdoc.v2
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
touch .env
|
||||
|
||||
@@ -10,8 +10,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,8 +18,19 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
- uses: sobolevn/misspell-fixer-action@06ff0b508d4f4c0ba70d15f9a628232c0aade536 # v0.1.0
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -1,10 +1,7 @@
|
||||
name: 'Pull Request Labeler'
|
||||
|
||||
# only docs uses the labeler at the moment
|
||||
on:
|
||||
pull_request_target:
|
||||
paths:
|
||||
- 'apps/docs/**/*'
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
@@ -13,4 +10,17 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
- id: label
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
|
||||
- name: Comment when api-deploy-required is auto-applied
|
||||
if: contains(steps.label.outputs.new-labels, 'api-deploy-required')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: 'The `api-deploy-required` label was auto-applied to this PR because it updates the API types. Ensure that the new or updated API, if any, is deployed on production before **removing the label** and merging this PR.',
|
||||
})
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
PROD_AWS_ROLE:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
|
||||
@@ -22,6 +22,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup the Supabase CLI
|
||||
uses: supabase/setup-cli@b60b5899c73b63a2d2d651b1e90db8d4c9392f51 # v1.6.0
|
||||
|
||||
@@ -26,9 +26,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
packages/pg-meta
|
||||
packages/tsconfig
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -20,7 +20,14 @@ jobs:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: apps
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps
|
||||
blocks
|
||||
examples
|
||||
i18n
|
||||
packages
|
||||
patches
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
@@ -36,54 +43,3 @@ jobs:
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
pnpm run test:prettier
|
||||
|
||||
# i18n is not a node package, so we handle that one separately
|
||||
format-i18n:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
i18n
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Download dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
pnpm exec prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
|
||||
|
||||
format-sql:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps/docs/pages
|
||||
apps/docs/content
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Download dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
# Check mdx files which contain sql code blocks
|
||||
grep -lr '```sql' apps/docs/{pages,content}/**/*.mdx | xargs pnpm exec prettier -c
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
- cron: '0 4 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
settings:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -70,7 +75,8 @@ jobs:
|
||||
image_digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: meta
|
||||
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4.6.0
|
||||
with:
|
||||
@@ -117,18 +123,22 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
env:
|
||||
IMAGE_VERSION: ${{ needs.settings.outputs.image_version }}
|
||||
x86_DIGEST: ${{ needs.release_x86.outputs.image_digest }}
|
||||
ARM_DIGEST: ${{ needs.release_arm.outputs.image_digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t supabase/studio:${{ needs.settings.outputs.image_version }} \
|
||||
supabase/studio@${{ needs.release_x86.outputs.image_digest }} \
|
||||
supabase/studio@${{ needs.release_arm.outputs.image_digest }}
|
||||
docker buildx imagetools create -t supabase/studio:${IMAGE_VERSION} \
|
||||
supabase/studio@${x86_DIGEST} \
|
||||
supabase/studio@${ARM_DIGEST}
|
||||
docker buildx imagetools create -t supabase/studio:latest \
|
||||
supabase/studio@${{ needs.release_x86.outputs.image_digest }} \
|
||||
supabase/studio@${{ needs.release_arm.outputs.image_digest }}
|
||||
supabase/studio@${x86_DIGEST} \
|
||||
supabase/studio@${ARM_DIGEST}
|
||||
echo "Published Registry Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Image | Link |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`supabase/studio:${{ needs.settings.outputs.image_version }}\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=${{ needs.settings.outputs.image_version }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`supabase/studio:${IMAGE_VERSION}\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=${IMAGE_VERSION}) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`supabase/studio:latest\` | [View on Docker Hub](https://hub.docker.com/r/supabase/studio/tags?name=latest) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
publish:
|
||||
@@ -139,4 +149,5 @@ jobs:
|
||||
uses: ./.github/workflows/mirror.yml
|
||||
with:
|
||||
version: ${{ needs.settings.outputs.image_version }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }}
|
||||
@@ -42,12 +42,14 @@ jobs:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
apps/www/.env.local.example
|
||||
examples
|
||||
packages
|
||||
supabase
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -17,12 +17,74 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config: [default, logs, envoy, rustfs, envoy-rustfs]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
docker/
|
||||
- name: Run docker-compose up
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Generate keys
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
sh utils/generate-keys.sh --update-env
|
||||
sh utils/add-new-auth-keys.sh --update-env
|
||||
|
||||
- name: Start self-hosted Supabase
|
||||
shell: bash
|
||||
# Ensure all services can be started and healthy with default config
|
||||
run: cd docker && cp .env.example .env && docker compose up --wait
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
yq -i '.services.supavisor.environment.RLIMIT_NOFILE=1024' docker-compose.yml
|
||||
if [ "${{ matrix.config }}" = "logs" ]; then
|
||||
docker compose -f docker-compose.yml -f docker-compose.logs.yml up --quiet-pull --wait --wait-timeout 180
|
||||
elif [ "${{ matrix.config }}" = "envoy" ]; then
|
||||
docker compose -f docker-compose.yml -f docker-compose.envoy.yml up --quiet-pull --wait --wait-timeout 180
|
||||
elif [ "${{ matrix.config }}" = "rustfs" ]; then
|
||||
docker compose -f docker-compose.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180
|
||||
elif [ "${{ matrix.config }}" = "envoy-rustfs" ]; then
|
||||
docker compose -f docker-compose.yml -f docker-compose.envoy.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180
|
||||
else
|
||||
docker compose up --quiet-pull --wait --wait-timeout 180
|
||||
fi
|
||||
|
||||
- name: Run container log tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
sh tests/test-container-logs.sh
|
||||
|
||||
- name: Run smoke tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
sh tests/test-self-hosted.sh
|
||||
|
||||
- name: Run API keys and asymmetric auth tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
sh tests/test-auth-keys.sh
|
||||
|
||||
- name: Run S3 protocol tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd docker
|
||||
sh tests/test-s3.sh
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Studio Docker Build
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Studio Docker Build'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
studio:
|
||||
- 'packages/pg-meta/**'
|
||||
- 'apps/studio/**'
|
||||
- 'apps/ui-library/**'
|
||||
- 'apps/design-system/**'
|
||||
- 'e2e/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/studio-e2e-test.yml'
|
||||
- name: Build
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --no-cache
|
||||
@@ -9,20 +9,29 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'E2E tests'
|
||||
timeout-minutes: 60
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2]
|
||||
shardTotal: [2]
|
||||
outputs:
|
||||
tests_ran: ${{ steps.filter.outputs.studio == 'true' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
@@ -35,11 +44,13 @@ jobs:
|
||||
- 'e2e/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/studio-e2e-test.yml'
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -55,13 +66,104 @@ jobs:
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: pnpm -C e2e/studio exec playwright install chromium --with-deps --only-shell
|
||||
|
||||
- name: 🚀 Run Playwright tests against Vercel Preview
|
||||
- name: Set up NextJS/Turbo cache
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
# See here for caching with `yarn`, `bun` or other package managers https://github.com/actions/cache/blob/main/examples.md or you can leverage caching with actions/setup-node https://github.com/actions/setup-node
|
||||
path: |
|
||||
apps/studio/.next/build
|
||||
apps/studio/.next/cache
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/studio/**/*.js', 'apps/studio/**/*.jsx', 'apps/studio/**/*.ts', 'apps/studio/**/*.tsx') }}
|
||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Reset supabase
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: rm -rf supabase && pnpm exec supabase init && mkdir supabase/functions
|
||||
|
||||
# Authenticate with AWS ECR to avoid rate limiting
|
||||
- name: configure aws credentials
|
||||
if: steps.filter.outputs.studio == 'true' && !github.event.pull_request.head.repo.fork
|
||||
uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.PROD_AWS_ROLE }}
|
||||
aws-region: us-east-1
|
||||
- uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
|
||||
if: steps.filter.outputs.studio == 'true' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
|
||||
- name: Pre-start diagnostics
|
||||
run: |
|
||||
docker ps -a
|
||||
sudo ss -tlnp | grep 54322 || echo "54322 free"
|
||||
|
||||
- name: Start supabase
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: SKIP_ASSET_UPLOAD=1 pnpm run e2e:setup:cli
|
||||
|
||||
- name: Failure diagnostics
|
||||
if: failure()
|
||||
run: |
|
||||
docker ps -a
|
||||
sudo ss -tlnp | grep 54322 || echo "54322 not listening"
|
||||
docker logs $(docker ps -aq) 2>&1 || true
|
||||
|
||||
- name: Build studio
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: SKIP_ASSET_UPLOAD=1 NODE_ENV=test NODE_OPTIONS="--max-old-space-size=4096" pnpm run build:studio
|
||||
|
||||
- name: 🚀 Run Playwright tests against local studio build
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
id: playwright
|
||||
run: pnpm e2e
|
||||
run: PWTEST_SHARD_WEIGHTS=62:38 pnpm e2e --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
if: always() && steps.filter.outputs.studio == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: e2e/studio/blob-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Fail job if tests failed
|
||||
if: steps.filter.outputs.studio == 'true' && steps.playwright.outcome != 'success'
|
||||
run: |
|
||||
echo "E2E tests failed" >&2
|
||||
exit 1
|
||||
|
||||
merge-reports:
|
||||
name: 'E2E reports'
|
||||
# Merge reports after playwright-tests, even if some shards have failed
|
||||
if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }}
|
||||
needs: [test]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v 5.0.0
|
||||
with:
|
||||
path: e2e/studio/blob-report
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge Playwright reports
|
||||
run: npx playwright merge-reports --config=e2e/studio/playwright.merge.config.ts -- e2e/studio/blob-report
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always() && steps.filter.outputs.studio == 'true'
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
@@ -70,14 +172,22 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment Playwright test results on PR
|
||||
if: always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: daun/playwright-report-comment@be9e270edd5ad86038604d3caa84a819a6ff6fed # v3.10.0
|
||||
if: always() && steps.filter.outputs.studio == 'true' && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
with:
|
||||
report-file: e2e/studio/test-results/test-results.json
|
||||
comment-title: '🎭 Playwright Test Results'
|
||||
|
||||
- name: Fail job if tests failed
|
||||
if: steps.filter.outputs.studio == 'true' && (steps.playwright.outcome != 'success' || steps.summarize.outputs.flaky_count > 0)
|
||||
run: |
|
||||
echo "E2E tests failed" >&2
|
||||
exit 1
|
||||
merge-results:
|
||||
name: 'E2E results'
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
needs: [test]
|
||||
if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }}
|
||||
steps:
|
||||
- name: All tests ok
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
- name: Some tests failed
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
@@ -16,10 +16,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
apps/studio
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -35,9 +37,18 @@ jobs:
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_AUTOFIX_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Decrease ESLint ratchet baselines and open PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -eo pipefail
|
||||
@@ -66,7 +77,7 @@ jobs:
|
||||
|
||||
git add apps/studio/.github/eslint-rule-baselines.json
|
||||
git commit --message "chore: decrease ESLint ratchet baselines"
|
||||
git push --force origin "$BRANCH"
|
||||
git -c credential.helper= push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${BRANCH}"
|
||||
|
||||
pr_url=$(gh pr list --state open --head "$BRANCH" --json url --jq '.[0].url // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$pr_url" ]; then
|
||||
|
||||
@@ -22,10 +22,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
apps/studio
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -41,5 +43,5 @@ jobs:
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run ratchet script
|
||||
- name: Run ratchet script
|
||||
run: pnpm --filter studio run lint:ratchet
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Alert on master breakage
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 'Selfhosted Studio E2E Tests'
|
||||
- 'Studio Unit Tests & Build Check'
|
||||
branches: [master]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Slack alert
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }}
|
||||
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
|
||||
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
|
||||
COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }}
|
||||
COMMIT_AUTHOR: ${{ github.event.workflow_run.head_commit.author.name }}
|
||||
REPO_URL: https://github.com/${{ github.repository }}
|
||||
run: |
|
||||
SHORT_SHA="${COMMIT_SHA:0:7}"
|
||||
FIRST_LINE=$(echo "$COMMIT_MESSAGE" | head -1)
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg workflow_name "$WORKFLOW_NAME" \
|
||||
--arg workflow_url "$WORKFLOW_URL" \
|
||||
--arg commit_sha "$COMMIT_SHA" \
|
||||
--arg short_sha "$SHORT_SHA" \
|
||||
--arg commit_msg "$FIRST_LINE" \
|
||||
--arg author "$COMMIT_AUTHOR" \
|
||||
--arg repo_url "$REPO_URL" \
|
||||
'{
|
||||
text: ":rotating_light: \($workflow_name) failed on master",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: ":rotating_light: master is broken"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "*Workflow:* <\($workflow_url)|\($workflow_name)>\n*Commit:* <\($repo_url)/commit/\($commit_sha)|\($short_sha)> \($commit_msg)\n*Author:* \($author)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
curl -f -X POST "$SLACK_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$PAYLOAD"
|
||||
@@ -11,9 +11,6 @@ on:
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches: [master, studio]
|
||||
paths:
|
||||
- 'apps/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
@@ -30,49 +27,76 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
test_number: [1]
|
||||
outputs:
|
||||
tests_ran: ${{ steps.filter.outputs.relevant }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/studio
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
relevant:
|
||||
- 'apps/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
if: steps.filter.outputs.relevant == 'true'
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
if: steps.filter.outputs.relevant == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: Install deps
|
||||
if: steps.filter.outputs.relevant == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ./
|
||||
- name: Run Tests
|
||||
if: steps.filter.outputs.relevant == 'true'
|
||||
env:
|
||||
# Default is 2 GB, increase to have less frequent OOM errors
|
||||
NODE_OPTIONS: '--max_old_space_size=3072'
|
||||
run: pnpm run test:ci
|
||||
working-directory: ./apps/studio
|
||||
- name: Upload coverage artifact
|
||||
if: steps.filter.outputs.relevant == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: studio-coverage
|
||||
path: ./apps/studio/coverage/lcov.info
|
||||
retention-days: 1
|
||||
|
||||
coveralls:
|
||||
needs: test
|
||||
if: ${{ always() && needs.test.result == 'success' && needs.test.outputs.tests_ran == 'true' }}
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/studio
|
||||
patches
|
||||
- name: Download coverage artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: studio-coverage
|
||||
path: ./coverage
|
||||
- name: Upload coverage results to Coveralls
|
||||
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6
|
||||
with:
|
||||
parallel: true
|
||||
flag-name: studio-tests
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path-to-lcov: ./apps/studio/coverage/lcov.info
|
||||
path-to-lcov: ./coverage/lcov.info
|
||||
base-path: './apps/studio'
|
||||
fail-on-error: false
|
||||
|
||||
finish:
|
||||
needs: test
|
||||
if: ${{ always() }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Coveralls Finished
|
||||
uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6
|
||||
with:
|
||||
parallel-finished: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [master]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-nimbus-sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,12 +16,15 @@ permissions:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
# Uses larger hosted runner as it significantly decreases build times
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
@@ -38,7 +41,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: pnpm exec turbo run typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run Lint
|
||||
run: pnpm exec turbo run lint
|
||||
run: pnpm run lint
|
||||
@@ -21,8 +21,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
packages
|
||||
patches
|
||||
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
name: Install pnpm
|
||||
|
||||
Loaded 100 of 9200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user