CLAUDE.md
CLAUDE.mdCLAUDE.mdroot
Quality
84/100
Scores the file, not the repository.Length
6,714 words
47 headings · 24 code blocksRepository
16k
— · pushed 0 days agoLast changed
3 days ago
First indexed 3 days ago.1# Spree Commerce — Development Rules23## Plans & Architecture Decisions45All feature plans live in `docs/plans/` using the template at `docs/plans/_template.md`. Never create plans elsewhere.67When proposing significant architectural changes:81. Check existing plans in `docs/plans/` for conflicts92. Create or update a plan using the template before implementing103. Pay special attention to "Constraints on Current Work" sections — these apply even when you're not implementing that plan directly1112Use `/project:create-plan` and `/project:update-plan` for plan management.1314Active plans (6.0 target, work pending):15- `6.0-multi-vendor-marketplace.md` — Open-source the marketplace core (Vendor, OrderGroup-based order splitting, commission engine with EU commission taxation, `VendorTransfer`/`VendorPayout` ledger + pluggable `PayoutProvider`) per spree/spree#13323. 6.0 headline feature; rebuilds the legacy Enterprise multi-vendor module as native models on the Cart/Order split. Basic Stripe Connect payouts (Express onboarding + on-fulfillment transfers) ship OSS in the monorepo, alongside the Stripe core gateway pulled in from the standalone `spree_stripe` repo (payment-sessions classes only, likely `spree/core` — decisions.md 2026-07-15); Enterprise keeps refund clawbacks/netting, reconciliation, KYC ops, DAC7 payout reports, facilitator taxes + Shopify/WooCommerce vendor apps.16- `6.0-cart-order-split.md` — Cart/Order model separation, dual-FK owner everywhere (`cart_id`/`order_id` exactly-one, `#owner` as method — NOT polymorphic), idempotent copy-on-completion (`Order#cart_id` unique, order-before-payment), Order state machine removed (Checkout::Requirements for steps; payment/fulfillment statuses derived-then-persisted via one recompute service). Expanded 2026-07-27; gates split-adjustments, fulfillment, multi-vendor17- `6.0-admin-api.md` — Admin REST API conventions, auth, endpoint list (~300 endpoints)18- `6.0-admin-spa.md` — React admin architecture, extension points, table registry, i18n + server-error mapping19- `6.0-product-types.md` — Prototype → ProductType rename, MetafieldDefinition schema enforcement20- `6.0-remove-master-variant.md` — Eliminate is_master, add default_variant_id FK on Product21- `6.0-split-adjustments.md` — Replace polymorphic Adjustment with TaxLine, Discount, Fee22- `6.0-typed-stock-movements.md` — Replace generic StockMovement with typed kinds + concrete FKs23- `6.0-normalize-state-to-status.md` — Rename state → status on Payment, Shipment, InventoryUnit, ReturnAuthorization, GiftCard24- `6.0-fulfillment-and-delivery.md` — Shipment→Fulfillment, ShippingMethod→DeliveryMethod, drop ShippingCategory, FulfillmentProvider strategy, pickup (merchant StockLocation) + pickup_point (third-party PickupPointProvider)25- `6.0-returns-exchanges-claims.md` — First-class Return, Exchange, Claim models replacing ReturnAuthorization/Reimbursement chain26- `6.0-platform-auth.md` — Drop Devise, own auth stack, User→Customer/Staff rename (RefreshToken shipped in 5.4)27- `6.0-tax-provider.md` — Per-Market TaxProvider, replaces TaxRate.adjust + Calculator; TaxRate gets direct country/state FKs (tax decoupled from Zone; Zone model dropped in 6.0 via `6.0-delivery-zones.md`)28- `6.0-delivery-rate-provider.md` — Per-DeliveryMethod DeliveryRateProvider wrapping Stock::Estimator (calculators stay — 2026-07-27 reversal), `store_id` on ShippingMethod, enriched DeliveryRate (carrier/service/date/metadata)29- `6.0-delivery-zones.md` — Zone → DeliveryZone with postal-code-range/prefix members (country-scoped); owns the full ~183-reference Zone consumer inventory; `Spree::Zone` dropped entirely by end of 6.0 (2026-07-27 — 6.0 is the breaking-change window)30- `6.0-rich-text-descriptions.md` — Drop ActionText **entirely** at 6.0 (incl. CustomFields::RichText values + Order/User internal notes; gem dependency removed), store sanitized HTML in text columns, serve `field` + `field_html`, **write via `field_html`** (read/write symmetry). description_html serializer shipped 5.4; sanitizer shipped in **5.6.2** with a permissive-but-safe configurable allowlist + `sanitize_rich_text` step in the 5.5→5.6 upgrade manifest (decisions.md 2026-07-27/28), tightened to the Tiptap set at 6.031- `6.0-inventory-operations.md` — StockTransfer lifecycle (draft → ready_to_ship → in_transit → received with partial receive), new `Spree::PurchaseOrder` + `Spree::Supplier` (renamed from Vendor — `Spree::Vendor` is the marketplace seller, see decisions.md 2026-07-14) replacing today's "external receive" hack, variant + stock-location stock history panels. Consumes the typed-movement primitives from `6.0-typed-stock-movements.md`.32- `6.0-replace-taxons-with-categories.md` — Split Taxon into Category (hierarchy) + Collection (flat/rule-based). `Spree::Category < Spree::Taxon` alias + Category API surface shipped in 5.5; table rename + Collection pending.33- `6.0-store-scoped-custom-field-definitions.md` — Add `store_id` + persisted `filter_key` to `Spree::MetafieldDefinition` (today global and computed), making uniqueness `(store_id, resource_type, filter_key)` with a DB index. Deferred from the 5.6 custom-field search/sort/filter work (schema change, not patch-safe). **Until then:** `filter_key` is a computed method — no Ransack predicates or `where`/`order` against it; definitions are global; uniqueness rests on a `CONCAT` validation with no index behind it.3435Multi-version plans (some phases shipped, some pending):36- `5.4-store-api-naming-standardization.md` — Standardize API naming against industry (address fields, discounts, customer_note, label, brand/last4, etc.). 5.4 model/API aliases shipped; 6.0 column/table renames pending.37- `5.4-6.0-eu-legal-compliance.md` — GDPR (data export/anonymization, consent timestamps), Omnibus (PriceHistory, lowest-in-30-days), Consumer Rights (withdrawal period). 5.4 PriceHistory + `prior_price` shipped; GDPR endpoints + withdrawal period still pending.38- `5.4-6.0-custom-fields-rename.md` — Rename Metafields → Custom Fields. 5.4 API bridge + 5.5 `Spree::CustomField`/`CustomFieldDefinition` constant aliases shipped; 6.0 model/table rename pending.39- `5.4-6.0-product-media-system.md` — Product-level media gallery. 5.5 data model (spree_variant_media, media_type, focal_point, external_video_url) shipped; admin UIs in progress; 6.0 cleanup pending.40- `5.5-6.0-order-cancellation-and-approval.md` — First-class `OrderCancellation` + `OrderApproval` models. 5.5 models + migrations shipped; 6.0 drops denormalized columns.41- `5.5-6.0-display-on-to-boolean.md` — Collapse `display_on` tri-state to a single `storefront_visible` boolean. 5.5 bridge (`storefront_visible` accessor + Ransacker on `Spree::DisplayOn`) shipped; 6.0 schema rename pending.42- `6.0-order-routing.md` — Two-tier extension: pluggable `Spree::OrderRouting::Strategy::Base` + STI subclasses of `Spree::OrderRoutingRule`. Phase 1 (5.5) shipped: `Channel`, `OrderRoutingRule`, strategy base + Rules + Reducer + Legacy, `preferred_stock_location_id` + `channel_id` on Order. Phase 2+ (6.0) layers Catalog/Company on top via `6.0-channels-catalogs-b2b.md`.43- `6.0-channels-catalogs-b2b.md` — Channel + ProductPublication (replaces StoreProduct) + single-owner Product (`belongs_to :store`) + Publishing card (legacy admin + SPA) + `Channel#default` boolean shipped in 5.5. Channel-level gated storefront access (`storefront_access` enum + channel-owned `guest_checkout`, both with store fallback, enforced in the v3 Store API) targeted for 5.6. Catalog, Company/CompanyLocation/CompanyContact for B2B **deferred to 6.1** (the B2B release — see `docs/plans/decisions.md` 2026-06-16; filename keeps `6.0-` prefix for stable cross-refs). Multi-store catalogs (historic `Product has_many :stores`) move to the `spree_multi_store` extension.44- `5.6-6.0-single-store-promotions-payment-methods.md` — Migrate `Spree::Promotion` + `Spree::PaymentMethod` from multi-store (`has_many :stores` via `spree_promotions_stores` / `spree_payment_methods_stores` join tables) to single-owner `belongs_to :store`, mirroring the 5.5 single-owner Product migration. 5.6 (implemented): `store_id` FK + required-store presence via `Spree::SingleStoreResource`, backfill rake task (loud per-record deprecation on shared records), shared `LegacyMultiStoreSupport` deprecation bridge, deletes the `ResourceController` `store_ids=` seam, deprecates `StoreScopedResource`; multi-store sharing moves to the `spree_multi_store` extension (join tables left intact). 6.0 cleanup: enforce `null: false`, drop join tables + bridges. Paired with `6.0-channels-catalogs-b2b.md`.45- `5.6-project-layout-and-dashboard.md` — React Dashboard Developer Preview packaging + `backend/` → `api/` project layout. Implemented: `<Dashboard />` shell export from `@spree/dashboard` (source-only, relative imports only), monorepo-canonical `packages/dashboard-starter` thin host (embedded standalone into the `@spree/cli` tarball at build time via `scripts/sync-dashboard-starter.mjs` — no template repo; create-spree-app delegates to the project-local `spree add dashboard`), `spree add dashboard` + create-spree-app dashboard phase (opt-in via `--react-dashboard` while WIP — not prompted; env carries only `VITE_API_PROXY_TARGET` — never secret keys, and never `VITE_SPREE_API_URL`, which would flip the SDK to absolute cross-origin URLs and break dev on CORS), npm release job for `@spree/dashboard{,-ui,-core}` (0.x → `next` tag). Pending: layout rename + `detectApiDir` dual-layout CLI, `spree upgrade layout`; optional public template repo at 6.0 GA.46- `5.6-dashboard-typed-plugin-routes.md` — Plugin file routes compiled into the host's TanStack route tree: `spree.dashboard.routes` marker + virtual-route-config composition in `@spree/dashboard/vite`, `createDashboardRouter` + `<Dashboard router>` ownership inversion, typed cast-free links, cross-package collision pre-flight with package-named errors. Runtime route registry stays for dynamic/in-app cases (catch-all is lowest priority). Implemented; published-tarball spike passed.4748Pending design work (drafts, no implementation yet):49- `5.7-payment-method-rules.md` — `Spree::PaymentMethodRule` STI on PaymentMethod (Channel / Market / OrderTotal / CustomerGroup rules), mirroring the PromotionRule/PriceRule/OrderRoutingRule pattern. Enforced solely through `Order#collect_frontend_payment_methods` (listing + `Payments::Create` + payment sessions all flow through it); admin/backoffice bypasses; no rules = available everywhere. Dashboard-only management (nested Admin API CRUD + `/payment_method_rules/types` discovery). Supersedes the "no distribution concept" rationale in `5.6-6.0-single-store-promotions-payment-methods.md`; per-channel provider credentials (multiple Stripe accounts, multi-entity setups) explicitly deferred for grooming — see decisions.md 2026-07-23.50- `5.7-channel-markets.md` — Optional Channel→Markets allowlist (`spree_channel_markets` join, empty = all markets). Enforced in market resolution (`set_market_from_country` + channel-aware `Spree::Current.market` fallback), channel-filtered Store API `/store/markets`, and order-level `market must be served by channel` validation. Composes with `MarketRule` from the payment-method-rules plan.51- `5.6-admin-spa-csv-import.md` — Universal dashboard CSV import over the existing `Spree::Import` pipeline (implemented). Admin API v3 surface (create via direct-upload signed blob, `complete_mapping`, `retry_failed_rows`, nested failed-rows index, write-scope gating), `client.imports` SDK resource, dashboard-core `ImportButton` (per-context `<Can>` gating, upload Sheet) + full-window wizard dialog driven by an `?import=` search param, with history under `/settings/imports` (new `audit` settings-nav group). Status via API polling — explicitly no ActionCable/Turbo Streams in the SPA; legacy per-row live feed replaced by polled counters + paginated failed-rows table.52- `5.5-6.0-resource-translations-api.md` — Admin API v3 translation management + React dashboard for all `Spree.translatable_resources`. Hybrid: embedded `translations` object on resource update + generic dedicated `…/:id/translations` endpoint (one registry-driven controller), self-describing field discovery, advisory server-side staleness. Canonical `{ locale → { field → value } }` shape (consistent with metafield-translations). Cross-record bulk = CSV import/export generalized across the registry (NOT a JSON bulk endpoint — no competitor ships one). Phase 1 (5.5) API; Phase 2 (6.0) coverage read + CSV generalization + staleness + centralized SPA page; Phase 3 folds in metafields.53- `5.4-centralized-translations-admin.md` — Centralized Translations admin page under Products, overview grid + bulk CSV import/export54- `5.4-metafield-translations.md` — Translate MetafieldDefinition names + Metafield text values (ShortText, LongText, RichText) via Mobility translation tables55- `5.5-admin-api-cli.md` — `spree api` command group in `@spree/cli` (gh-api-style generic verbs + schema introspection + layered auth, CLI-first ahead of MCP servers; core patch: `SCOPES` on `spree:cli:create_api_key`, promotions scopes)5657Shipped plans:58- `5.4-store-api-bridges.md` — Bridge 6.0 naming into 5.4 Store API (PR #13782)59- `5.4-spree-starter-and-create-spree-app.md` — Replace monorepo server/ with spree-starter template repo60- `5.4-option-type-enhancements.md` — `kind` (dropdown/color_swatch/buttons) on OptionType + `color_code` on OptionValue61- `5.4-search-provider.md` — Pluggable SearchProvider interface (Database + Meilisearch); PgSearch + `add_search_scope` removed (6.0 MetafieldDefinition faceting still pending)62- `5.4-disjunctive-option-faceting.md` — Per-option-type filter params with disjunctive facet counts (`FiltersAggregator` for DB, `merge_disjunctive_facets` for Meilisearch)63- `6.0-stock-reservations.md` — Time-limited stock reservations during checkout (PR #13978; Cart/Order split integration + `allocated_count` term still pending for 6.0)64- `5.5-admin-api-key-scopes.md` — granular `read_*`/`write_*` scopes on `Spree::ApiKey` for app authorization65- `5.5-admin-auth-cookie-refresh.md` — Admin SPA refresh token in httpOnly cookie, access token in memory, server-side logout66- `5.5-admin-customers-api.md` — Admin Customers + nested addresses/credit_cards/store_credits + CustomerGroups67- `5.5-admin-spa-csv-export.md` — Admin API ExportsController + admin-sdk + `useExport` + toolbar export button68- `5.5-agent-skills.md` — `spree/agent-skills` standalone repo: 25 Claude Code skills + `spree-expert` subagent + safety hooks, distributed via `npx skills add spree/agent-skills`6970## Monorepo Structure7172| Directory | Description |73|---|---|74| `spree/core` | Ruby gem — models, services, business logic (`spree_core`) |75| `spree/api` | Ruby gem — Store & Admin REST APIs (`spree_api`) |76| `spree/emails` | Ruby gem — transactional emails (optional). Rebuilt + modernized in 5.6. The default email stack for installations without a storefront app (e.g. mobile apps); headless storefronts may instead own consumer emails via webhooks. |77| `spree/dashboard` | Ruby gem (`spree_dashboard`, optional) — hosts a built React Dashboard at `/dashboard` from `Spree::Dashboard.dist_path` / `SPREE_DASHBOARD_DIST_PATH` (single-node topology). Successor slot to `spree_admin` at 6.0. |78| `packages/dashboard` | `@spree/dashboard` — React SPA admin dashboard (Spree 6.0, replaces `spree/admin`). The deployable app shell, routes, schemas, resource hooks, locales. |79| `packages/dashboard-ui` | `@spree/dashboard-ui` — design system. Shadcn primitives + headless composed components + tokens. Source-only; consumer compiles via Vite/Tailwind. **Components are headless: data comes via props, no provider/hook imports.** |80| `packages/dashboard-core` | `@spree/dashboard-core` — framework. Registries (table, nav, slot, settings-nav), providers (auth, permission, store, theme), generic infra hooks, admin SDK client singleton, `defineDashboardPlugin` facade. The extension API for plugin authors. |81| `packages/dashboard-starter` | `@spree/dashboard-starter` — thin host app consuming `<Dashboard />` from `@spree/dashboard`; canonical source of the `spree/dashboard-starter` template repo (synced on release). Doubles as the in-repo consumer test for the plugin pipeline. |82| `packages/sdk` | `@spree/sdk` — TypeScript Store API client |83| `packages/admin-sdk` | `@spree/admin-sdk` — TypeScript Admin API client (Developer Preview) |84| `packages/sdk-core` | `@spree/sdk-core` — shared HTTP/retry/error layer (private internal) |85| `packages/cli` | `@spree/cli` — Docker-based project management CLI |86| `packages/create-spree-app` | `create-spree-app` — project scaffolding |87| `server/` | Rails app cloned from `spree/spree-starter` (.gitignored, run `pnpm server:setup`) |8889## Development Server (`server/`)9091One-time bootstrap (Docker required, no host Ruby): `pnpm install && pnpm server:setup`. It clones spree-starter into `server/`, boots the edge stack (monorepo gems bind-mounted via a compose overlay), and prepares + seeds the DB. Idempotent — but re-running it is a **full reset** that wipes the DB and volumes.9293Day-to-day from the repo root: `pnpm server:dev` (foreground — streams web logs; jobs run in-process via Solid Queue; Ctrl+C stops it, postgres stays warm) / `server:stop` (full teardown) / `server:restart` / `server:logs` / `server:console` / `server:seed` / `server:load_sample_data`. CLI commands run from `server/`: `pnpm exec spree <cmd>` (`spree migrate`, `spree console`, `spree generate model …`). `spree dev` and `spree build` refuse to run in `server/` (SPREE_PATH guard) — use the `pnpm server:*` scripts instead.9495| What changed | What to run |96|---|---|97| Ruby code in `spree/*` gems | Nothing — bind-mounted, reloads on next request |98| Hosted React Dashboard at `/dashboard` (single-node test) | `pnpm server:dashboard` — rebuilds `packages/dashboard-starter/dist` with `VITE_BASE_PATH=/dashboard/`; served immediately through the monorepo mount (no restart). For dashboard *development* keep using Vite on :5173 (`cd packages/dashboard && pnpm dev`). |99| Tailwind classes in `spree/admin` templates/helpers/JS | Nothing — a watcher in the web container rebuilds the admin CSS within ~15s. If changes still don't reach the browser, delete `server/public/assets/.manifest.json` (stale precompile output that freezes asset serving) and restart web — `pnpm server:dev` boots handle this automatically. |100| New migration in a gem | Nothing — the next `pnpm server:dev` boot runs `spree:install:migrations db:prepare` (or `cd server && pnpm exec spree migrate` while running) |101| Gem dependencies (gemspec / Gemfile / starter `Gemfile.lock` drift after a pull) | Nothing — the next `pnpm server:dev` boot self-heals (`bundle check || bundle install` into the `bundle_cache` volume); while running: `cd server && pnpm exec spree bundle install` |102| Compose files / `server/.env` | `pnpm server:dev` (force-recreates web + worker) |103| `server/Dockerfile` / `.ruby-version` / starter update that breaks the image build (frozen-lockfile error) | `pnpm server:build`, then `pnpm server:dev` — the build script swaps the edge PATH lock for a RubyGems-resolved one and the next boot swaps it back |104| Working on the Meilisearch search provider | Off by default (DB provider) — `SPREE_MEILISEARCH=1 pnpm server:dev` chains `scripts/docker-compose.meilisearch.yml` (service + `MEILISEARCH_URL`), then `cd server && pnpm exec spree rake spree:search:reindex` once. Booting without the flag reverts web to DB search and removes the meilisearch container; the index volume stays. Image bump ("database version … is incompatible"): `pnpm server:stop && docker volume rm server_meilisearch_data`, flagged boot, reindex |105| Broken beyond repair | `pnpm server:setup` (full reset — wipes DB + volumes) |106107Backend: http://localhost:3000, admin at `/admin`, hosted React Dashboard at `/dashboard` (`spree@example.com` / `spree123`). Native no-Docker path: `pnpm server:create`, then `cd server && bin/setup && bin/dev`.108109---110111## General rules112113- ONLY comment complex or non-obvious methods/code, do not comment every method or class, DON'T create comments noise114- Commit message body: be precise, DON'T include implementation detail, focus on the "what" and "why", not the "how"115- If n-commits are needed for a single logical change, use `git commit --fixup` for the follow-ups and `git rebase -i --autosquash` to combine into a single commit before merging116- Documentation also needs to follow the same principles — focus on the "what" and "why", not the "how". Don't include implementation details in docs. Docs should explain the feature, its purpose, and how to use it, but not how it's implemented internally.117- NEVER commit anything to main branch, always use feature/fix/chore branches for development118119## Backend (Ruby)120121### Architecture Principles122123- All code namespaced under `Spree::` module124- Follow Rails conventions and the Rails Security Guide125- RESTful routes and action names126- CanCanCan for authorization: listings use `accessible_by(current_ability, :show)`, other actions use `authorize!`127- Always use scope fetching for security (e.g. `current_store.orders` not `Spree::Order`)128- Ransack for filtering/searching, Pagy for pagination129- Use services only when necessary — prefer standard Rails models and concerns130- DO NOT call `Spree::User` directly, use `Spree.user_class`; same for `Spree.admin_user_class`131- DO NOT put logic into controllers or serializers - this should live in models and services132- ALWAYS use Yard comments for classes and public methods, with `@param` and `@return` types133- DO NOT generate too much comment noise, be very strict and selective about what gets a comment — only non-obvious public methods, never private methods or internal helpers134135### Code Organization136137All backend code lives inside `spree/` engine directories following Rails conventions:138139- `app/models/spree/`, `app/controllers/spree/`, `app/services/spree/`, `app/serializers/spree/`, `app/subscribers/spree/`, `app/mailers/spree/`, `app/jobs/spree/`, `app/helpers/spree/`, `app/presenters/spree/`140- File naming matches class: `spree/product.rb` → `Spree::Product`141- Split large models into concerns, organized by topic142143### Spree::Current144145Per-request context available in models, controllers, jobs, and services:146147- `Spree::Current.store` — current store148- `Spree::Current.currency` — current currency149- `Spree::Current.locale` — current locale150151### Models152153- ALWAYS Inherit from `Spree.base_class`154- ALWAYS pass `class_name` and `dependent` on associations; use `dependent: :destroy_async` for high-fanout associations to offload deletion to a background job155- Include `Spree::Metafields` for custom fields support (see docs/plans/5.4-6.0-custom-fields-rename.md)156- Include `Spree::Metadata` for JSON metadata support157- ALWAYS Use string columns instead of enums158- State machines: use `state_machines-activerecord` gem, default column `status` (legacy uses `state`, see docs/plans/6.0-normalize-state-to-status.md)159- NEVER cast IDs to integer — always treat as strings (UUID support)160- Uniqueness validations: ALWAYS use `scope: spree_base_uniqueness_scope`, should be also enforced by database index161- If needed use paranoia gem for soft delete support (via `acts_as_paranoid`)162- For configuration / options always use [Model Preferences](docs/developer/customization/model-preferences.mdx)163- NEVER hardcode table names, always use `Model.table_name` in models, queries, scopes, etc.164- ALWAYS use Arel, scopes and ActiveRecord helpers to build queries, only use raw SQL if cannot use Arel165166```ruby167class Spree::Product < Spree.base_class168 include Spree::Metafields169 include Spree::Metadata170171 acts_as_paranoid172173 has_many :variants, class_name: 'Spree::Variant', dependent: :destroy174 scope :available, -> { where(available_on: ..Time.current) }175176 validates :name, presence: true177 validates :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }178end179```180181### Migrations182183- Target version: `ActiveRecord::Migration[7.2]` (Rails 7.2 support)184- No foreign key constraints185- No default values186- Always add `null: false` on required columns187- One migration per feature when possible188- Data transformations go in rake tasks, never in migrations189- Soft delete: use `paranoia` gem, add `deleted_at` column yourself190- JSON columns must work across PostgreSQL, MySQL, and SQLite. PostgreSQL supports `t.jsonb` (binary, indexable); MySQL and SQLite do not — only `t.json`. Guard with `respond_to?`:191192```ruby193# JSON column — works on PostgreSQL, MySQL, SQLite194if t.respond_to?(:jsonb)195 t.jsonb :metadata196else197 t.json :metadata198end199```200201```ruby202class CreateSpreeMetafields < ActiveRecord::Migration[7.2]203 def change204 create_table :spree_metafields do |t|205 t.string :key, null: false206 t.text :value, null: false207 t.string :kind, null: false208 t.string :visibility, null: false209 t.references :resource, polymorphic: true, null: false210 t.timestamps211 end212213 add_index :spree_metafields, [:resource_type, :resource_id, :key, :visibility],214 name: 'index_spree_metafields_on_resource_and_key_and_visibility'215 end216end217```218219### API Controllers220221The Store API (customer-facing) and Admin API (back-office) are two halves of the same v3 API and should follow the same conventions. The differences are in **what data is exposed**, **who can call it**, and **which actions are enabled by default** — not in routing style, parameter shape, or response format.222223#### Hierarchy224225- **Base:** `Spree::Api::V3::ResourceController` — pagination (Pagy), Ransack, CanCanCan, prefixed ID lookups, HTTP caching226- **Store API:** `Spree::Api::V3::Store::ResourceController` — publishable API key auth, **read-only by default**; opt into `create`/`update`/`destroy` per resource where it makes sense (carts, customers, addresses)227- **Admin API:** `Spree::Api::V3::Admin::ResourceController` — secret API key auth (with scopes) **or** JWT auth (with CanCanCan), **full CRUD by default** (`index`, `show`, `create`, `update`, `destroy`); subclasses don't need to redeclare actions unless restricting228229#### Key overridable methods230231`model_class`, `serializer_class` (use `Spree.api.serializer_name`), `scope` (call `super` and chain), `find_resource`, `permitted_params`, `collection_includes`232233#### Flat request/response structure234235API v3 uses flat params — no nested Rails-style wrapping. **For new controllers, prefer enumerating attributes directly with `params.permit(...)`** rather than reaching into `Spree::PermittedAttributes`. Existing controllers that use the global allowlist remain valid until migrated as part of the 6.0 transition.236237```ruby238# ✅ Flat params239def permitted_params240 params.permit(:name, :description, :slug)241end242243# ❌ Nested params — not used in API v3244def permitted_params245 params.require(:product).permit(:name, :description, :slug)246end247```248249**Read and write attribute names must match.** Whatever a serializer exposes (`label`, `status`, `customer_note`) is what the controller's `permitted_params` must accept on write — no "we expose `label` but accept `presentation`" mismatches. This is non-negotiable for v3: clients should not have to translate field names between read and write. When the underlying column has a legacy name, define a writer alias on the **model** (`def label=(value); self.presentation = value; end` — pair it with the matching reader) and permit the public name in the controller. The model owns the bridge, never the client. Example: `Spree::OptionType#label` / `label=` aliases — the serializer returns `label`, the controller permits `:label`, and the model translates to the underlying `presentation` column.250251```ruby252module Spree::Api::V3::Store253 class ProductsController < ResourceController254 protected255256 def model_class257 Spree::Product258 end259260 def serializer_class261 Spree.api.product_serializer262 end263264 def scope265 super.active(Spree::Current.currency)266 end267 end268end269```270271```ruby272# Admin counterpart — gets full CRUD for free from the base class273module Spree::Api::V3::Admin274 class ProductsController < ResourceController275 protected276277 def model_class278 Spree::Product279 end280281 def serializer_class282 Spree.api.admin_product_serializer283 end284285 # No need to declare index/show/create/update/destroy — inherited.286 # Only override scope/find_resource/permitted_params when behavior differs.287 end288end289```290291### Prefixed IDs292293All API v3 uses Stripe-style prefixed IDs (e.g. `prod_86Rf07xd4z`, `variant_k5nR8xLq`):294295- Always return prefixed IDs in responses — never expose raw IDs296- Always accept prefixed IDs in request params297- `BaseSerializer` auto-converts the primary `id`; for associations use `object.association&.prefixed_id`298- Controllers use `find_by_prefix_id!` (automatic in base `ResourceController`)299- Event payloads also use prefixed IDs300301```ruby302# ✅ Serializer303attribute :variant_id do |line_item|304 line_item.variant&.prefixed_id305end306307# ❌ Exposes raw ID308attribute :variant_id309```310311### Serializers (Alba)312313Located in `api/app/serializers/spree/api/v3/`. Store and Admin APIs have separate serializers; **Admin always extends Store** so changes to public fields propagate automatically.314315#### What goes where316317The Store API is a customer-facing surface. The Admin API is a back-office surface. Two rules govern which serializer an attribute belongs to:318319**Store serializer (customer-visible):**320- Public product/category/cart/order data the customer sees in the storefront321- Computed display values (`display_total`, `purchasable`, `in_stock`)322- Customer-facing pricing (`price`, `compare_at_price`, `prior_price` for EU Omnibus)323- **No timestamps** (`created_at`, `updated_at`, `deleted_at`) — these leak operational info and aren't useful to customers324- **No internal state** — never expose `cost_price`, internal status flags, soft-delete columns, audit logs, internal notes, private metadata, or admin-only relations (vendors, fulfillment providers)325326**Admin serializer (back-office):**327- Always include `created_at`, `updated_at`, and `deleted_at` (when paranoid)328- Cost price, margins, internal notes, private metadata329- Internal status, audit fields (`approved_by_id`, `cancelled_by_id`)330- Operational relations (stock movements, fulfillment providers, internal customer tags)331- Anything an admin needs to see but a customer must not332333```ruby334# Store serializer — customer-facing, no timestamps, no back-office data335module Spree::Api::V3336 class ProductSerializer < BaseSerializer337 typelize purchasable: :boolean, in_stock: :boolean, price: 'number | null'338 attributes :id, :name, :description, :slug, :price339 end340end341342# Admin serializer — extends store, adds back-office attributes + timestamps343module Spree::Api::V3::Admin344 class ProductSerializer < V3::ProductSerializer345 typelize cost_price: 'number | null', private_metadata: 'Record<string, unknown> | null'346 attributes :status, :cost_price, :private_metadata, :created_at, :updated_at, :deleted_at347 end348end349```350351- `typelize attr: :type` for computed/delegated attribute types352- Never use `typelize_from` — it connects to the database353- Customize via inheritance + `Spree.api.product_serializer = 'MyApp::ProductSerializer'`354355### Events System356357```ruby358order.publish_event('order.completed')359```360361Subscribers go in `app/subscribers/spree/`:362363```ruby364module Spree365 class OrderCompletedSubscriber < Spree::Subscriber366 subscribes_to 'order.completed'367368 def handle(event)369 order = Spree::Order.find_by_prefix_id(event.payload['id'])370 return unless order371 ExternalService.notify_order_placed(order)372 end373 end374end375```376377For new models, add `publishes_lifecycle_events` concern and create an event serializer.378379### API Authentication380381Four credential types, each with its own header and authorization model:382383- **Publishable keys** (`pk_xxx`) — Store API, `X-Spree-API-Key` header. Identifies the store; permits public/guest endpoints. Safe to expose in client-side code.384- **Secret keys** (`sk_xxx`) — Admin API, `X-Spree-API-Key` header. **Server-to-server only.** Each key carries a list of [granular scopes](docs/plans/5.5-admin-api-key-scopes.md) (`read_products`, `write_orders`, etc.) that gate which endpoints it can hit. Authorization is scope-based, not CanCanCan-based.385- **JWT tokens** — user auth, `Authorization: Bearer <token>` header. Used by both Store API (logged-in customer) and Admin API (logged-in admin user). Admin JWT auth uses **CanCanCan abilities** for authorization, not scopes — this is what the admin SPA uses.386- **Guest cart tokens** — `X-Spree-Token` header. Authorizes operations on a specific guest cart.387388Admin API authorization summary:389- Secret API key + scopes → for apps and integrations (audit-friendly, fine-grained)390- JWT + CanCanCan → for human admin users (role-based)391392Both code paths converge at the same controllers; the controller checks permissions appropriately based on which credential authenticated the request.393394### Dependencies System395396Register swappable services in `Spree::Dependencies`:397398```ruby399Spree::Dependencies.cart_add_item_service = 'Spree::Cart::AddItem'400```401402### Security403404- CanCanCan permission checks on all actions405- Use Rails [`params.permit`](https://api.rubyonrails.org/classes/ActionController/Parameters.html) to whitelist parameters in controllers406- Use `Spree.user_class` / `Spree.admin_user_class` — never reference user models directly407- Declare Ransack allowlists on **models** via `whitelisted_ransackable_attributes`, `whitelisted_ransackable_associations`, and `whitelisted_ransackable_scopes` to control which attributes, associations, and scopes are queryable from API requests408409### Performance410411- Use `includes`/`preload` to avoid N+1 queries (`ar_lazy_preload` gem also active)412- Use `Rails.cache` for expensive operations; use `cache_key_with_version` for custom keys413- Proper database indexing414415### I18n416417- Use `Spree.t` for translations418- Keep translations in `config/locales/en.yml` — no duplication across files419420### Documentation421422- Re-generate OpenAPI spec after API changes: `bundle exec rake rswag:specs:swaggerize`423- OpenAPI spec: `docs/api-reference/store.yaml` (generated from `spree/api/spec/integration`)424- Update developer docs in `docs/developer/` when relevant425- DO NOT edit the OpenAPI specs manually, it is generated from the integration tests. If you need to change the spec, change the integration tests instead and run swaggerize to regenerate the spec.426427---428429## Frontend (TypeScript)430431### Workspace Setup432433Managed with **pnpm** workspace + **Turbo** for task orchestration. All packages use **Tsup** for building and **Vitest** for testing.434435```bash436pnpm install # install all workspace deps437pnpm build # build all packages (Turbo-cached)438pnpm test # run all package tests439pnpm typecheck # TypeScript validation across all packages440pnpm lint # Biome lint across all packages441pnpm lint:fix # Biome lint + auto-fix442pnpm format # Biome format-write443```444445**Linting:** All TypeScript packages use [Biome](https://biomejs.dev/) (replaces ESLint + Prettier). Root config at `biome.json`; per-package configs extend it via `"extends": ["../../biome.json"]` and set `"root": false`. CI runs `pnpm turbo lint` on every PR touching `packages/**`.446447### @spree/sdk — Store API Client448449TypeScript SDK for the customer-facing Store API v3.450451**Structure:**452- `src/client.ts` — `createClient()` factory, `ClientConfig` interface453- `src/store-client.ts` — all REST endpoints as resource classes (`client.products.list()`, `client.carts.create()`, etc.)454- `src/types/generated/` — auto-generated TypeScript types from Alba serializers455- `src/zod/generated/` — auto-generated Zod schemas for runtime validation456457**Patterns:**458- Flat resource pattern: `client.products.list()`, `client.carts.items.create()`459- Auth modes: publishable key (guest), JWT (customer)460- Automatic retry with exponential backoff461- `SpreeError` class with code, status, details462- Ransack query params transformed via `transformListParams()` in sdk-core463464**Testing:** Vitest + MSW (Mock Service Worker) for HTTP mocking. Tests in `tests/`.465466```bash467cd packages/sdk468pnpm build # tsup build (CJS + ESM)469pnpm test # vitest470pnpm generate:zod # regenerate Zod schemas from TS types471pnpm typecheck472```473474### @spree/admin-sdk — Admin API Client475476Same patterns as `@spree/sdk` but for the Admin API. Supports both secret key (server-to-server) and JWT (admin SPA) authentication. Published under the `next` dist-tag during the Spree 6.0 Developer Preview.477478### @spree/dashboard — Admin UI (React SPA)479480The Spree 6.0 admin dashboard — a Vite-built React SPA that replaces the legacy Rails `spree/admin` engine entirely. Tech stack: Vite, TanStack Router (file-based, type-safe), TanStack Query, React Hook Form + Zod, shadcn/ui + Base UI + Tailwind, Biome, Vitest. All API calls go through `@spree/admin-sdk`. See [`packages/dashboard/README.md`](packages/dashboard/README.md) and `docs/plans/6.0-admin-spa.md` for the full architecture (auth, permissions, multi-store, extension points, the three-package split).481482**Package boundary rules** (see `docs/plans/6.0-admin-spa.md` → "Package Split"):483- `@spree/dashboard-ui` — primitives + headless compounds. Components accept data via props, never import providers or hooks.484- `@spree/dashboard-core` — registries, providers, generic infra hooks, admin SDK client singleton, `defineDashboardPlugin`.485- `@spree/dashboard` — routes, resource hooks (`use-orders`, `use-products`, …), Zod schemas, locales, app shell.486487The split lets plugin authors register UI via `defineDashboardPlugin` from `@spree/dashboard-core/plugin`, build new pages with `@spree/dashboard-ui` primitives, and reuse the same providers/hooks. It also lets app developers compose custom dashboards (e.g. vendor panels) from the same packages.488489**Running the admin UI locally:**490491```bash492# 1. Boot a Spree backend (one terminal, from monorepo root)493pnpm server:setup # one-time bootstrap (see "Development Server" above)494pnpm server:dev # foreground; streams logs — Rails on http://localhost:3000495496# 2. Boot the admin (separate terminal, from monorepo root)497pnpm turbo dev --filter=@spree/dashboard-starter # http://localhost:5173 (proxies /api/* to :3000)498```499500The starter is the canonical host — the same app `spree add dashboard` scaffolds — so local dev exercises the real consumer path (shell + plugin pipeline) while still hot-reloading `@spree/dashboard`/`-core`/`-ui` source through the workspace. `turbo dev` (not a bare `pnpm dev` inside the package) matters on a fresh clone: the starter's `vite.config.ts` resolves the compiled Node-side Vite entries (`@spree/dashboard/vite`, `@spree/dashboard-core/vite`) from `dist/`, and turbo's `^build` dependency produces them. After any full `pnpm build`, `cd packages/dashboard-starter && pnpm dev` works too.501502`VITE_API_PROXY_TARGET` overrides the backend the dev proxy targets (default `http://localhost:3000`); don't use `VITE_SPREE_API_URL` in dev — it flips the SDK to absolute cross-origin URLs, bypassing the proxy. Sign in with the seed admin user (`spree@example.com` / `spree123` — override at seed time with `ADMIN_EMAIL` / `ADMIN_PASSWORD`; see `spree/core/app/services/spree/seeds/admin_user.rb`).503504**When implementing a new admin feature:**5055061. **The Admin API is the only data source.** Never reach into Rails models or import server-rendered HTML. If a needed endpoint or attribute is missing, add it to `spree/api` first (see backend conventions above), regenerate types via the [Type Generation Pipeline](#type-generation-pipeline), then consume it from the SPA.5072. **Look at the legacy Rails admin in `spree/admin/`** for what the feature does today (data shape, business rules, edge cases) — but don't port the UX 1:1. The SPA can do better than Turbo-era full-page reloads where it meaningfully improves the experience.5083. **Follow `docs/plans/6.0-admin-spa.md`** for the three extension points (table registry, navigation registry, component injection) and the shadcn copy-paste ownership model.5094. **Wrap SDK calls in custom hooks** under `src/hooks/` (e.g. `useOrders`, `useProduct`) — never call `adminClient` directly from components.510511**Translations.** Every user-visible string in `@spree/dashboard` goes through i18next — page titles, headings, table column labels, button labels, empty states, toast messages, confirm dialog copy, select option labels, badges, status text, tooltips, helper text. Never hardcode English (or any language) into JSX, into table column definitions, or into dropdown option arrays. Keys live in `packages/dashboard/src/locales/en.json` (app-specific copy) or `packages/dashboard-core/src/locales/en.json` (cross-cutting: `admin.common.*`, `admin.fields.<attribute>.<facet>`). Reach for `i18n.t(...)` at module load (table definitions) and `useTranslation().t(...)` inside components. **Schemas in `src/schemas/` hold canonical values only — never label strings.** Build `{ value, label }` pairs at render time inside the component by mapping the canonical value list against translation keys. Same goes for the legacy Rails admin: every label/hint goes through `Spree.t` / `I18n.t` against `spree/admin/config/locales/en.yml`; run `bundle exec i18n-tasks normalize` after adding keys. When adding a new translation key, ALWAYS add it to the all languages files in `packages/dashboard/src/locales/` and `packages/dashboard-core/src/locales/`.512513**Forms.** Raw React Hook Form with `<Field>` / `<Input>` / `<FieldError>` blocks. Drive each input explicitly with `form.register(...)` or a `<Controller>` for custom widgets so the form reads top-to-bottom. Wrap RHF's `handleSubmit` with a try/catch that calls `mapSpreeErrorsToForm` (`@/lib/form-errors`) to route 422 responses onto `form.formState.errors`: flat attribute keys become field errors with `aria-invalid` + `<FieldError>`; `:base` and nested keys land on `errors.root.message` so render a destructive banner at the top of the form.514515```tsx516async function handleSubmit(values: FormValues) {517 try {518 await onSubmit(values)519 } catch (err) {520 if (!mapSpreeErrorsToForm(err, form.setError)) throw err521 }522}523```524525- **Labels/placeholders/help** come from `packages/dashboard/src/locales/en.json` under `admin.fields.<resource>.<attribute>.{label,placeholder,help}` with cross-resource fallback `admin.fields.<attribute>.<facet>`. Dev mode logs missing keys to the console.526- **Client validation** lives in the Zod schema (`zodResolver`).527- **Mutation hooks built on `useResourceMutation` suppress their own toast for 422 responses** — the form already shows the inline message. Non-validation errors (network, 5xx, gateway) still toast. For a plain `useMutation` you want a fallback toast on, layer the catch: try `mapSpreeErrorsToForm` first, re-throw `SpreeError`, otherwise `toast.error(...)`.528529**Form schemas** live in `packages/dashboard/src/schemas/<resource>.ts` when shared across 2+ files or non-trivial (~30+ lines, nested sub-schemas, companion constants); inline is fine for short single-file forms. The schema file owns the Zod schema, its inferred `FormValues` type, defaults, dropdown option arrays, and regex constants. **Don't add form↔API mappers to paper over field renames** — if you find yourself translating `ot.label → form.presentation`, fix the API instead (read/write symmetry, see "API Controllers" above). Mappers are only for pure frontend state (upload progress, transient UI bookkeeping).530531**Base UI `<Select>` does not auto-render labels.** Unlike Radix, Base UI's `<Select.Value />` renders the raw selected `value` (the slug, the ISO code, the prefixed ID) instead of the matching `<SelectItem>`'s children. Two fixes:5325331. **Static option labels** — pass an `items` array; Base UI resolves the trigger label automatically:534```tsx535 <Select items={KIND_OPTIONS} value={...} onValueChange={...}>536 <SelectTrigger><SelectValue /></SelectTrigger>537 <SelectContent>538 {KIND_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}539 </SelectContent>540 </Select>541```5422. **Dynamic option labels** — use the children render-prop:543```tsx544 <SelectValue>{(value) => roles.find((r) => r.id === value)?.name ?? (value as string)}</SelectValue>545```546547For free-text **searchable** pickers, use `<Combobox>` instead — see `components/spree/country-state-fields.tsx`.548549**`acts_as_list` ⇒ drag-and-drop reorder, never a numeric position input.** When a model uses `acts_as_list`, both top-level list tables and nested collection editors must reorder via dnd-kit:5505511. **Top-level resource tables**: pass `reorder={{ onReorder: (id, position) => adminClient.X.update(id, { position }) }}` to `<ResourceTable>` — it owns the `DndContext` + `SortableContext` internally, optimistic with rollback. Reference: `routes/_authenticated/$storeId/settings/payment-methods.tsx`.5522. **Nested collection editors** (e.g. `option_values[]` on an option-type sheet): wrap `useFieldArray` rows in `DndContext` + `SortableContext`, give each row a `<GripVerticalIcon>` grip with `{...attributes} {...listeners}` from `useSortable`, and on drag end call `valuesArray.move(from, to)` and rewrite each row's `position` to its new index. The position field is **not rendered**; it's a computed output. Reference: `routes/_authenticated/$storeId/products/options.tsx` (vertical), `routes/_authenticated/$storeId/products/$productId.tsx` (product media grid).553554Use `verticalListSortingStrategy` for rows/lists, `rectSortingStrategy` for grids. Always pair `PointerSensor` (with `activationConstraint: { distance: 5 }` so row clicks don't hijack as drags) with `KeyboardSensor` + `sortableKeyboardCoordinates` for accessibility.555556**`<StoreDatePicker>` is the only correct way to render a date/datetime field.** Never use `<Input type="date">` (native styling breaks the design system) or the bare `<DatePicker>` in `components/ui/` (skips the store timezone). `@/components/spree/store-date-picker` reads the store's IANA timezone from `<StoreProvider>` so every datetime in the SPA means the same thing for every admin. Modes:557558- **Date-only** (default): emits `yyyy-MM-dd` strings (timezone-agnostic). Persist as-is — backend `date` columns accept these directly via Ransack.559- **Datetime** (`includeTime`): the user picks a wall-clock time in the store's timezone; the picker emits the corresponding UTC ISO string and reinterprets it on read.560561Wire through `<Controller>` in forms; pass `value`/`onChange` directly in filter panels. **Inside a `<Sheet>`, pass `inline`** — the default Popover path hits the portal bug below.562563**Base UI `<Popover>` is unreliable inside a `<Sheet>`'s portal tree.** Symptom: the trigger gets `aria-expanded="true"` and `data-popup-open=""` on click, but no `[data-slot="popover-content"]` ever appears in the DOM. Happens in deeply-nested portal trees (Sheet → SortableContext → TableRow → Popover). Fix: render the panel inline with `absolute top-full left-0 z-50` + a `document.pointerdown` click-outside listener + Escape-to-close. A portal is only needed to escape an `overflow: hidden` ancestor; for table cells and form fields, inline is fine. Reference: `components/spree/color-picker.tsx`, plus `<StoreDatePicker inline>` above.564565### @spree/sdk-core — Shared HTTP Layer566567Private package providing `createRequestFn()`, `SpreeError`, retry logic, and Ransack param transformation. Used internally by both SDKs.568569### Type Generation Pipeline570571When changing Alba serializers, run the full pipeline:572573```bash574cd spree/api && bundle exec rake typelizer:generate # 1. TS types from serializers575cd packages/sdk && pnpm generate:zod # 2. Zod schemas from TS types576cd spree/api && bundle exec rspec spec/integration/ # 3. Integration tests577bundle exec rake rswag:specs:swaggerize # 4. OpenAPI spec578cd packages/sdk && pnpm test # 5. SDK tests579```580581- TypeScript types → `packages/sdk/src/types/generated/` (Store) and `packages/admin-sdk/src/types/generated/` (Admin)582- Zod schemas → `packages/sdk/src/zod/generated/`583- Store types: `StoreProduct`, `StoreOrder`, etc. Admin types: `AdminProduct`, `AdminOrder`, etc.584585A **Lefthook pre-commit hook** (`lefthook.yml`) regenerates types and Zod schemas automatically whenever `spree/api/app/serializers/**/*.rb` files are committed, then re-stages the generated output. You don't need to run steps 1 and 2 manually if you're committing serializer changes — the hook handles it. Steps 3–5 (integration tests, OpenAPI regen, SDK tests) still need to run locally before pushing.586587### Changesets & Versioning588589Published packages use **Changesets** for versioning — one workspace-wide instance. Place changeset files in the root `.changeset/` directory (`pnpm changeset`), never in per-package directories. The dashboard packages (`@spree/dashboard`, `@spree/dashboard-core`, `@spree/dashboard-ui`) are a `fixed` group and always release together under one version; `@spree/admin-sdk` versions independently. Two release trains: `pnpm version:preview` cuts the Developer Preview packages while holding back `@spree/sdk` (stable, tracks Spree releases); `pnpm changeset version` includes it. `--ignore` defers changesets, it never discards them.590591---592593## Testing594595Always run tests before committing changes.596597### Backend (Ruby — RSpec)598599Each engine has its own test suite:600601```bash602cd spree && bundle install # shared deps603cd core && bundle install # engine deps604bundle exec rake test_app # create dummy Rails app (skip if already exists)605bundle exec rspec # run full suite606bundle exec rspec spec/models/spree/state_spec.rb # single file607bundle exec rspec spec/models/spree/state_spec.rb:7 # single test608```609610Default DB is SQLite3. For PostgreSQL:611612```bash613DB=postgres DB_USERNAME=postgres DB_PASSWORD=password DB_HOST=localhost bundle exec rake test_app614```615616**Parallel runs:**617618```bash619bundle exec rake parallel_setup # create worker DBs620bundle exec parallel_rspec spec # run in parallel621bundle exec parallel_rspec -n 4 spec # with worker count622```623624Re-run `parallel_setup` after schema changes.625626**Test guidelines:**627- RSpec + Factory Bot628- Prefer `build` over `create` for speed629- Factories live in `lib/spree/testing_support/factories/`630- ALWAYS use factories in tests, never call `Model#create` directly631- ALWAYS run parallel tests if running full test suite, if there are any failures repeat the failed examples seperately and confirm they really fail before investigating632- Pragmatic — no tests for standard Rails validations, only custom ones633- Controller specs: always add `render_views`, use `stub_authorization!` for auth634- Use controller specs for testing edge cases, API integration tests are only for happy path/simple 422 failures to generate OpenAPI examples; otherwise they get too brittle and high-maintenance635- Time-based tests: use `Timecop`636- Don't over-engineer or repeat tests637638### Frontend (TypeScript — Vitest)639640```bash641cd packages/sdk && pnpm test # SDK tests (uses MSW for HTTP mocking)642```643644### Admin SPA E2E (Playwright)645646End-to-end tests for `packages/dashboard` live in `packages/dashboard/e2e/`. The global setup boots a real Rails test server (port 3010) + Vite (port 5174) once and seeds the DB; specs then exercise the SPA through a browser against that stack. Locally Vite runs in dev mode; CI builds first and serves the bundle (`E2E_PREVIEW=1` → `vite preview`) because every test's fresh browser context re-downloads all dev-mode modules. CI also splits the suite across shard jobs (`--shard=n/m`), each with its own isolated Rails + SQLite + Vite stack — specs must stay self-contained (seed via global-setup fixtures or create your own records) and must not depend on records another spec file leaves behind.647648```bash649cd packages/dashboard && pnpm test:e2e # full suite650cd packages/dashboard && pnpm test:e2e:ui # Playwright UI mode (debug)651```652653The `login(page)` helper authenticates through the API (one POST plants the refresh cookie; the SPA's boot-time silent refresh does the rest) — only `auth.spec.ts` drives the login form itself.654655**Write UI-only assertions, like Capybara.** Drive the test through user-visible actions (fill labels, click buttons, find by role) and assert on visible UI. **Do not** reach for `page.waitForResponse(/api/...)` to wait for backend completion — it leaks API shape into tests and makes refactors painful. Playwright's `await expect(...).toBeVisible()` auto-polls until the condition is met (same as Capybara's `default_max_wait_time`), which covers virtually all cases.656657```ts658// ✅ Capybara-style: drive the UI, assert on the UI.659await page.getByLabel(/^label$/i).fill('Color')660await page.getByRole('button', { name: /create option type/i }).click()661await expect(page.getByRole('button', { name: 'color' })).toBeVisible({ timeout: 15_000 })662663// ❌ Avoid: couples the test to API shape, brittle on refactor.664await Promise.all([665 page.waitForResponse((res) => /\/api\/v3\/admin\/option_types/.test(res.url()) && res.status() === 201),666 page.getByRole('button', { name: /create option type/i }).click(),667])668```669670The narrow exceptions where API-level waits are justified:671- **No UI feedback** — a mutation kicks off background work (e.g., a webhook fire-and-forget) and there's nothing visible to assert against.672- **Optimistic UI** — success state appears in the DOM before the API confirms; a UI-only assertion can't distinguish "rendered and persisted" from "rendered but later failed."673674Both are rare in the admin SPA, which renders success states only after mutations resolve.675676**Conventions:**677- Use `Date.now()` suffixes on names so leftover rows from earlier specs don't collide (the suite runs serially — `fullyParallel: false, workers: 1`).678- Disambiguate duplicate button names (e.g., a "Delete" in the sheet footer + another in a confirm dialog) by scoping: `page.getByRole('dialog').getByRole('button', { name: /^delete$/i })`.679- Reference: `e2e/option-types.spec.ts`, `e2e/invitation-acceptance.spec.ts`.680
Similar configs
Same format, overlapping stack, ranked by quality.
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| dotCMS/corecore-web/CLAUDE.md · 949 | CLAUDE.md | teststylearchtesting-strategy+3 | 100/100 | 3 days ago | |
| dotCMS/corecore-web/libs/sdk/react/CLAUDE.md · 949 | CLAUDE.md | setupbuildtestlint-format+9 | 97/100 | 3 days ago | |
| khrnchn/sedekah-jeCLAUDE.md · 89 | CLAUDE.md | testlint-formatstylearch+6 | 97/100 | 3 days ago | |
| carrot-foundation/middle-earthCLAUDE.md · 0 | CLAUDE.md | setupbuildtestlint-format+6 | 97/100 | 3 days ago | |
| modelcontextprotocol/serversCLAUDE.md · 89k | CLAUDE.md | setupbuildtestlint-format+6 | 97/100 | 3 days ago | |
| oven-sh/buntest/CLAUDE.md · 95k | CLAUDE.md | teststyletesting-strategydo-not | 97/100 | 3 days ago | |
| BattlefieldNoob/Project-ShishaCLAUDE.md · 0 | CLAUDE.md | setupbuildtestlint-format+4 | 97/100 | 2 days ago | |
| MetaMask/metamask-design-systemCLAUDE.md · 34 | CLAUDE.md | buildtestlint-formatstyle+5 | 97/100 | 3 days ago |
