Skip to content

feat(racetrack): a hosted playground and scenario-driven test runner for PTE plugins#2641

Closed
christianhg wants to merge 7 commits into
mainfrom
feat/racetrack-v0-poc
Closed

feat(racetrack): a hosted playground and scenario-driven test runner for PTE plugins#2641
christianhg wants to merge 7 commits into
mainfrom
feat/racetrack-v0-poc

Conversation

@christianhg
Copy link
Copy Markdown
Member

@christianhg christianhg commented May 11, 2026

Racetrack is a hosted playground and scenario-driven test runner for Portable Text Editor plugins. Wire your plugin in, drive it with scenarios you author visually, watch them go green. The same .feature file runs in Racetrack playback AND in the customer's vitest CI - identical results.

This is the long-running branch where Racetrack is built inside the editor monorepo while PTE v7 stabilises. It will not be merged in this shape. Platform fixes that fall out (one already has, #2644) get split into their own PRs and merged independently.

What's here today

The app at apps/racetrack/ is private, runs against the monorepo's workspace sources, and currently boots a fixed garage of two cars:

  • Mention picker - @portabletext/plugin-typeahead-picker's 19 scenarios, lifted as-is.
  • Input rule: edge cases - @portabletext/plugin-input-rule's 57 expanded scenarios across nine text-transform rules sharing one editor.

Both run end-to-end and match their vitest CI baseline exactly (19/19 and 57/57).

Four panels

  • Track (left) - the .feature file rendered with one status dot per step. Idle, running, pass, fail.
  • Paddock (centre) - a live PT editor running the car's plugin component for hands-on exploration.
  • Engine - the car's source files (Plugin.tsx, entry.tsx, steps.ts, feature.feature) in a read-only tab strip.
  • Race control (right) - Start race; the runner mounts a second isolated editor in a hidden test target and drives it through every scenario, painting back into Track as it goes.

Switching cars from the header dropdown swaps the playground component, the engine source, the feature file, and the runner's hooks together.

How it actually runs in the browser

The step library at packages/editor/src/test/vitest/step-definitions.tsx imports userEvent, page, and expect.element from vitest/browser, plus vi.waitFor/expect from vitest. createTestEditor imports render from vitest-browser-react. Those are virtual modules vitest's runtime injects only inside the test worker, so racetrack ships its own shims at apps/racetrack/src/runner/ that implement just enough surface to drive the editor through scenarios in the browser:

  • vitest-browser-shim.ts - wraps @testing-library/user-event for keyboard/click/type, plus minimal page and Locator.
  • vitest-shim.ts - vi.waitFor and the small expect polyfill used by the editor matchers.
  • vitest-browser-react-shim.ts - a render() that uses React 19's createRoot and scopes the container to [data-racetrack-test-target].

vite.config.ts aliases vitest/browser, vitest, and vitest-browser-react to these shims. Step definitions and createTestEditor compile and run unchanged; the same code therefore runs in two contexts - in CI it resolves to vitest's real runtime, in Racetrack to the shims.

The runner re-implements racejar's vitest gherkin driver in 163 lines at src/runner/browser-runner.ts, emitting onStep callbacks the UI subscribes to. compileFeature from racejar's root export is runner-agnostic and does the heavy lifting.

Surfacing real platform gaps

Racetrack has already produced two upstream fixes worth landing on their own:

  1. createTestEditor's returned locator was unscoped - it discarded render()'s scoped locator and built a document-wide page.getByRole('textbox') lookup. Hidden by vitest's iframe-per-test isolation; surfaced when two editors share a DOM. Split out as #2644.
  2. userEvent.type in this branch's shim collapsed contenteditable selections - @testing-library/user-event's .type() clicks first, which the vitest/browser and Playwright equivalents do not. Six input-rule scenarios that combined programmatic caret placement (the caret is put after "c") with typing failed in racetrack while passing in vitest CI. Fixed in apps/racetrack/src/runner/vitest-browser-shim.ts (this branch's 242c2cef9).

Garage shape

apps/racetrack/src/garage/
  index.ts                          # registry of entries
  types.ts                          # GarageEntry contract
  mention-picker/
    entry.tsx                       # manifest: id, hooks, steps, engine files
    Plugin.tsx                      # the playground/runner React component
    steps.ts                        # plugin-specific Then-steps
    feature.feature                 # the track
  input-rule-edge-cases/
    entry.tsx
    Plugin.tsx
    feature.feature

Adding a new car is three files plus a line in index.ts. The runner panel is generic over GarageEntry; the playground panel renders entry.PlaygroundComponent; the engine panel reads entry.engine (an array of {name, language, source} objects loaded via ?raw at build time).

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: 04957f5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@portabletext/editor Patch
racetrack Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (ab440431)

@portabletext/editor

Metric Value vs main (ab44043)
Internal (raw) 744.1 KB -
Internal (gzip) 142.8 KB -
Bundled (raw) 1.35 MB -
Bundled (gzip) 303.8 KB -
Import time 97ms -0ms, -0.3%

@portabletext/editor/behaviors

Metric Value vs main (ab44043)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 3ms +0ms, +2.0%

@portabletext/editor/plugins

Metric Value vs main (ab44043)
Internal (raw) 3.6 KB -
Internal (gzip) 1021 B -
Bundled (raw) 3.4 KB -
Bundled (gzip) 952 B -
Import time 8ms +0ms, +0.6%

@portabletext/editor/selectors

Metric Value vs main (ab44043)
Internal (raw) 76.3 KB -
Internal (gzip) 14.3 KB -
Bundled (raw) 72.4 KB -
Bundled (gzip) 13.3 KB -
Import time 8ms -0ms, -0.9%

@portabletext/editor/traversal

Metric Value vs main (ab44043)
Internal (raw) 9.2 KB -
Internal (gzip) 2.4 KB -
Bundled (raw) 9.3 KB -
Bundled (gzip) 2.4 KB -
Import time 5ms -0ms, -0.4%

@portabletext/editor/utils

Metric Value vs main (ab44043)
Internal (raw) 30.6 KB -
Internal (gzip) 6.5 KB -
Bundled (raw) 28.4 KB -
Bundled (gzip) 6.1 KB -
Import time 6ms -0ms, -0.5%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 11, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​testing-library/​user-event@​14.6.110010010086100

View full report

@vercel
Copy link
Copy Markdown

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment May 11, 2026 2:40pm
portable-text-example-basic Ready Ready Preview, Comment May 11, 2026 2:40pm
portable-text-playground Ready Ready Preview, Comment May 11, 2026 2:40pm

Request Review

Racetrack is the hosted playground and scenario-driven test runner for
Portable Text Editor plugins. Customers wire their plugin into
Racetrack, drive it with scenarios authored visually, watch them go
green. The same `.feature` file runs in Racetrack playback AND in the
customer's vitest CI - identical results.

This v0 POC is hard-coded to the mention-picker plugin and exists to
prove the loop end-to-end across three panels:

- Scenarios (left): `mention-picker.feature` rendered with one status
  dot per step; idle, running, pass, or fail. After a run the failing
  step's error message paints in red.
- Playground (centre): a live `EditorProvider` with the mention-picker
  wired in. Type `@` to trigger the picker for real.
- Runner (right): a "Run scenarios" button that instantiates a second,
  isolated editor inside the panel and drives it through every
  scenario via `createTestEditor` from
  `@portabletext/editor/test/vitest`. Each scenario's Before hook
  spins up a fresh editor; pass/fail results paint back into the left
  panel.

The interesting engineering is the shim. Step definitions in
`packages/editor/src/test/vitest/step-definitions.tsx` import
`userEvent`, `page`, and `expect.element` from `vitest/browser`, plus
`vi.waitFor`/`expect`/`assert` from `vitest`. `createTestEditor`
imports `render` from `vitest-browser-react`. All three are virtual
modules vitest's runtime injects only inside the test worker.

Racetrack does not run inside vitest, so `vite.config.ts` aliases
`vitest/browser`, `vitest`, and `vitest-browser-react` to local files
in `src/runner/`. The shims implement just enough surface to cover
the call sites in `step-definitions.tsx` and `test-editor.tsx`,
wrapping `@testing-library/user-event` for keyboard / click / type and
a tiny `expect` polyfill for the value and element matchers used.
Anything not yet covered throws a loud error so the next missing
surface shows up immediately.

The same source code therefore runs in two contexts: in the
customer's vitest CI it resolves to vitest's real runtime, in
Racetrack to our shims. Identical step definitions, identical
assertions, two execution environments.
…red editor

`createTestEditor` from `@portabletext/editor/test/vitest` discarded the scoped locator from its underlying `render()` and built a document-wide `page.getByRole('textbox')` lookup. Two calls in the same DOM would have the second one's locator address the first editor. Vitest's iframe-per-test isolation hid this; embedders that share a DOM across editors hit it.

Use the scoped locator from `render()` so each editor's locator targets exactly that editor.

Add a regression test that mounts two editors, types different text into each, asserts each editor ended up with its own content.
The runner mounts a second editor inside `<div data-racetrack-test-target />` alongside the playground editor. Both editors render `<MentionPickerPlugin>`, so both emit `data-testid="keyword"`, `"matches"`, `"state"`, `"selectedIndex"` into the DOM. `attachLocators` used `page.getByTestId(...)` unscoped, which matched the playground's plugin (higher in document order) and read its state instead of the runner's, so every state-reading scenario failed even though the keyboard was reaching the test-target editor.

Scope the four locators to `page.locator('[data-racetrack-test-target]')` so each query starts inside the runner's container.

Add `locator(selector)` to the `vitest/browser` shim's `Locator` and `page` surfaces; it was the last missing primitive needed by this scoping pattern.

19/19 mention-picker scenarios pass.
@vercel vercel Bot temporarily deployed to Preview – portable-text-playground May 11, 2026 13:25 Inactive
@vercel vercel Bot temporarily deployed to Preview – portable-text-editor-documentation May 11, 2026 13:25 Inactive
Restructure racetrack to load any plugin by id from a small registry.
Each entry owns its feature file, plugin component, hook construction,
and any plugin-specific steps. The runner panel is now generic over
entry; the playground panel renders the entry's component inside an
`<EditorProvider>`.

The header shows the current entry and an entry picker dropdown.
Switching entries resets the run state. The right-side panel reads
`feature.scenarios.length` from the parsed feature, but parsed now
goes through `@cucumber/gherkin` directly so Scenario Outlines expand
one pickle per Examples row, matching what `compileFeature` produces.
This keeps the left panel's index-per-step in lockstep with the
runner's `onStep` callbacks.

Add a second entry: input-rule edge cases, lifted from
`packages/plugin-input-rule/src/edge-cases.test.tsx`. Nine text
transform rules sharing one editor, declarative steps only, no custom
locator scoping. 51 of 57 scenarios green; 6 scenarios touching
inline objects diverge from vitest CI (57/57 there). Known limitation
in racetrack's textspec / userEvent interaction around `{stock-ticker}`
inline objects; tracked separately.

Vocabulary aligned to the racing metaphor: 'Start race' / 'Racing...'
button, 'Track' for the scenarios panel, 'Paddock' for the playground,
'Race control' for the runner, 'finished/crashed' for pass/fail.
`@testing-library/user-event`'s `.type(element, text)` clicks the
element first before typing, which collapses the contenteditable
selection to wherever the click lands. `vitest/browser`'s
`userEvent.type` and Playwright's `page.keyboard.type` type at the
current selection without re-clicking.

PTE step definitions like `the caret is put after "c"` set the
selection via `editor.send({type: 'select', ...})` and then call
`userEvent.type(locator, text)` expecting the next keystroke to land
at the placed caret. With auto-click that selection got blown away
and typing always landed at the end of the editor, which broke every
scenario that mixed programmatic caret placement with inline objects.

Pass `skipClick: true` and only focus the element if it isn't already
the active element. Closes the gap: input-rule edge-cases scenarios
that touch `{stock-ticker}` inline objects now match vitest CI
(57/57).
…le deps

Adds `@cucumber/gherkin`, `@cucumber/messages`, and `@portabletext/plugin-input-rule` to `apps/racetrack`'s installed tree. `@cucumber/*` were transitive via racejar; promoting them to direct deps so racetrack's feature-parser can use them without violating the monorepo's transitive-import discipline.
Adds a fourth panel between Paddock and Race control that shows the car's source files in a read-only viewer with a tab strip per file. Each garage entry now declares an `engine` field: an array of `{name, language, source}` entries loaded via `?raw` imports at build time, so the viewer shows exactly what is on disk without bundling a syntax-highlighting server.

mention-picker exposes Plugin.tsx, entry.tsx, steps.ts, feature.feature. input-rule edge-cases exposes Plugin.tsx, entry.tsx, feature.feature.

Tab switching across files works without round-trips; switching cars rebuilds the tab strip from the new entry's engine array.
@christianhg christianhg changed the title feat(racetrack): v0 POC feat(racetrack): a hosted playground and scenario-driven test runner for PTE plugins May 11, 2026
@christianhg christianhg deleted the feat/racetrack-v0-poc branch May 11, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant