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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions .github/workflows/rust-playground.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: Rust playgrounds

# rust/apps: the desktop playgrounds on the Rust port. Each is a Cargo
# workspace of its own (Rune needs a newer Rust than lexical-editor's 1.88).
# Path-filtered because macOS runners are slow to start. Linux checks that
# the Rune app builds, passes its tests, and renders headless; macOS builds
# and renders it as it runs on tmm's Mac. The GPUI app is macOS only here: it
# runs an end-to-end session in a real window, and JS replays the session.

on:
push:
branches: [main]
paths:
- 'rust/**'
- 'packages/lexical-conformance/vectors/**'
- '.github/workflows/rust-playground.yml'
# Any base, so a PR stacked on another one gets checked too.
pull_request:
paths:
- 'rust/**'
- 'packages/lexical-conformance/vectors/**'
- '.github/workflows/rust-playground.yml'

concurrency:
group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
rune-linux:
name: Rune playground (Linux)
runs-on: ubuntu-24.04
timeout-minutes: 40
defaults:
run:
working-directory: rust/apps/lexical-playground-rune
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Rune's windowing libraries, and a software Vulkan driver for its
# headless renderer on a runner without a GPU.
- name: Install system libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev mesa-vulkan-drivers
# rustup is preinstalled; rust-toolchain.toml picks the version.
- name: Install the toolchain
run: rustup show active-toolchain || rustup toolchain install
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
rust/apps/lexical-playground-rune/target
key: lexical-playground-rune-${{ runner.os }}-${{ hashFiles('rust/apps/lexical-playground-rune/Cargo.lock', 'rust/apps/lexical-playground-rune/rust-toolchain.toml') }}
restore-keys: lexical-playground-rune-${{ runner.os }}-
- name: Format
run: cargo fmt --check
- name: Lint
run: cargo clippy --all-targets --locked -- -D warnings
- name: Test
run: cargo test --locked
# The app's first frame with a typed session, drawn by Rune's own
# renderer as a window would.
- name: Render headless
run: |
cargo run --locked -- --snap "$RUNNER_TEMP/lexical-playground-rune-linux.png" --size 1100x720 --scene typed
test -s "$RUNNER_TEMP/lexical-playground-rune-linux.png"
# The Parity panel after Replay vectors, run in the app.
- name: Render the parity panel
run: |
cargo run --locked -- --snap "$RUNNER_TEMP/lexical-playground-rune-parity.png" --size 1100x720 --scene parity
test -s "$RUNNER_TEMP/lexical-playground-rune-parity.png"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: lexical-playground-rune-linux-snapshot
path: |
${{ runner.temp }}/lexical-playground-rune-linux.png
${{ runner.temp }}/lexical-playground-rune-parity.png
if-no-files-found: ignore

rune-macos:
name: Rune playground (macOS)
runs-on: macos-26
timeout-minutes: 40
defaults:
run:
working-directory: rust/apps/lexical-playground-rune
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install the toolchain
run: rustup show active-toolchain || rustup toolchain install
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
rust/apps/lexical-playground-rune/target
key: lexical-playground-rune-${{ runner.os }}-${{ hashFiles('rust/apps/lexical-playground-rune/Cargo.lock', 'rust/apps/lexical-playground-rune/rust-toolchain.toml') }}
restore-keys: lexical-playground-rune-${{ runner.os }}-
- name: Lint
run: cargo clippy --all-targets --locked -- -D warnings
- name: Test
run: cargo test --locked
- name: Render headless
run: |
cargo build --locked --release
./target/release/lexical-playground-rune --snap "$RUNNER_TEMP/lexical-playground-rune-macos.png" --size 1100x720 --scene typed
test -s "$RUNNER_TEMP/lexical-playground-rune-macos.png"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: lexical-playground-rune-macos-snapshot
path: ${{ runner.temp }}/lexical-playground-rune-macos.png
if-no-files-found: ignore

gpui-macos:
name: GPUI playground (macOS)
runs-on: macos-26
timeout-minutes: 45
defaults:
run:
working-directory: rust/apps/lexical-playground-gpui
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install the toolchain
run: rustup show active-toolchain || rustup toolchain install
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
rust/apps/lexical-playground-gpui/target
key: lexical-playground-gpui-${{ runner.os }}-${{ hashFiles('rust/apps/lexical-playground-gpui/Cargo.lock', 'rust/apps/lexical-playground-gpui/rust-toolchain.toml') }}
restore-keys: lexical-playground-gpui-${{ runner.os }}-
- name: Format
run: cargo fmt --check
- name: Lint
run: |
cargo clippy --all-targets --locked -- -D warnings
cargo clippy --all-targets --locked --features e2e -- -D warnings
- name: Test
run: cargo test --locked
# The end-to-end run (src/e2e.rs): the app types, composes and deletes
# in its own window through the input handler and key bindings, and
# exports the session...
- name: Run end to end
run: |
cargo build --locked --features e2e
LEXICAL_PLAYGROUND_E2E="$RUNNER_TEMP/gpui-session.json" \
LEXICAL_PLAYGROUND_E2E_SHOT="$RUNNER_TEMP/lexical-playground-gpui-e2e.png" \
./target/debug/lexical-playground-gpui
# ...which JS Lexical replays, comparing every step with its own.
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 11.24.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
package-manager-cache: false
- run: pnpm install --frozen-lockfile
working-directory: .
- name: Replay the session in JS
working-directory: packages/lexical-conformance
run: LEXICAL_REPLAY="$RUNNER_TEMP/gpui-session.json" pnpm exec vitest run src/replay.test.ts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: lexical-playground-gpui-e2e
path: |
${{ runner.temp }}/lexical-playground-gpui-e2e.png
${{ runner.temp }}/gpui-session.json
if-no-files-found: ignore
77 changes: 73 additions & 4 deletions packages/lexical-conformance/src/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
*/

import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {
$caretRangeFromSelection,
$createNodeSelection,
Expand All @@ -28,7 +29,12 @@ import {
$selectAll,
$setSelection,
$setSelectionFromCaretRange,
type AnyLexicalCommand,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
type CaretDirection,
CLEAR_EDITOR_COMMAND,
CLEAR_HISTORY_COMMAND,
COMMAND_PRIORITY_EDITOR,
createCommand,
createEditor,
Expand All @@ -39,10 +45,12 @@ import {
type NodeCaret,
ParagraphNode,
type PointCaret,
REDO_COMMAND,
resetRandomKey,
RootNode,
type SerializedLexicalNode,
TextNode,
UNDO_COMMAND,
} from 'lexical';

// A small, JSON-only script language for editor sessions, interpreted here by
Expand Down Expand Up @@ -95,7 +103,11 @@ export type Action =
| {do: 'logNormalizeCaret'; caret: CaretSpec}
| {do: 'dispatch'; command: string; payload?: Json};

/** A command handler: logs, runs its actions, then returns `result`. */
/**
* A command handler: logs, runs its actions, then returns `result`. Like
* `dispatch`, `command` names one of Lexical's own commands (CORE_COMMANDS)
* or a command the script makes.
*/
export type HandlerSpec = {
id: string;
command: string;
Expand All @@ -104,10 +116,16 @@ export type HandlerSpec = {
actions?: Action[];
};

/** A step: one discrete update, or a command dispatched outside an update. */
export type Step =
/**
* A step: one discrete update, or a command dispatched outside an update.
* `now` sets the session's clock (in ms, what `Date.now()` returns while
* steps run) before the step; without it the clock keeps its last value,
* which starts at 0.
*/
export type Step = (
| {update: Action[]; tag?: string[]}
| {dispatch: string; payload?: Json};
| {dispatch: string; payload?: Json}
) & {now?: number};

export type Script = {
name: string;
Expand All @@ -116,6 +134,11 @@ export type Script = {
handlers?: HandlerSpec[];
/** Log update, mutation and text content listener calls. */
listen?: boolean;
/**
* `registerHistory(editor, createEmptyHistoryState(), delay)` (@lexical/history),
* registered after the handlers and listeners, on the session's clock.
*/
history?: {delay: number};
steps: Step[];
/** Record `state` after every step (default), or after the last only. */
snapshot?: 'every' | 'final';
Expand Down Expand Up @@ -455,7 +478,24 @@ function $runAction(action: Action, ctx: Context): void {
}
}

/**
* Lexical's own commands, which `dispatch` and handlers name as JS exports
* them. Any other name is a command the script makes.
*/
const CORE_COMMANDS = new Map<string, AnyLexicalCommand>([
['CAN_REDO_COMMAND', CAN_REDO_COMMAND],
['CAN_UNDO_COMMAND', CAN_UNDO_COMMAND],
['CLEAR_EDITOR_COMMAND', CLEAR_EDITOR_COMMAND],
['CLEAR_HISTORY_COMMAND', CLEAR_HISTORY_COMMAND],
['REDO_COMMAND', REDO_COMMAND],
['UNDO_COMMAND', UNDO_COMMAND],
]);

function command(ctx: Context, name: string): LexicalCommand<Json> {
const core = CORE_COMMANDS.get(name);
if (core !== undefined) {
return core;
}
let cmd = ctx.commands.get(name);
if (cmd === undefined) {
cmd = createCommand<Json>(name);
Expand Down Expand Up @@ -586,7 +626,36 @@ export function startSession(script: Omit<Script, 'steps'>): Session {
ctx.log.push(['textContent', content]);
});
}
// The session's clock: `Date.now()` returns it while a step runs,
// including the read below that commits a dispatch's update, so the
// history listener sees it.
let clock = 0;
const atClock = <T>(f: () => T): T => {
const dateNow = Date.now;
Date.now = () => clock;
try {
return f();
} finally {
Date.now = dateNow;
}
};
if (script.history !== undefined) {
const {delay} = script.history;
atClock(() =>
// `registerHistory`'s default clock would keep the `Date.now` of the
// moment it registers; this one reads it at every update.
registerHistory(editor, createEmptyHistoryState(), delay, () =>
Date.now(),
),
);
}
const run = (step: Step, snapshot: boolean): StepResult => {
if (step.now !== undefined) {
clock = step.now;
}
return atClock(() => runAtClock(step, snapshot));
};
const runAtClock = (step: Step, snapshot: boolean): StepResult => {
ctx.log = [];
let error = false;
try {
Expand Down
49 changes: 49 additions & 0 deletions packages/lexical-conformance/src/replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {readFileSync} from 'node:fs';
import {describe, expect, test} from 'vitest';

import {type Script, startSession, type StepResult} from './ops';

// Replays a session another port recorded, such as one exported from a
// desktop playground in rust/apps, and checks that JS Lexical produces the
// same result at every step:
//
// cd packages/lexical-conformance
// LEXICAL_REPLAY=/path/to/session.json pnpm exec vitest run src/replay.test.ts
//
// The file is a `scripts/*` vector file (`{kind: 'script', cases}`) or one
// case. Without LEXICAL_REPLAY the test is skipped.

type Case = Script & {results: StepResult[]};

const path = process.env.LEXICAL_REPLAY;

function cases(): Case[] {
const file = JSON.parse(readFileSync(path as string, 'utf8')) as
| Case
| {cases: Case[]};
return 'cases' in file ? file.cases : [file];
}

describe.skipIf(path === undefined)('replay', () => {
for (const c of path === undefined ? [] : cases()) {
test(c.name, () => {
const session = startSession(c);
c.steps.forEach((step, i) => {
const expected = c.results[i];
const actual = session.run(step, expected.state !== undefined);
expect(
{...actual, step: i},
`step ${i}: ${JSON.stringify(step)}`,
).toEqual({...expected, step: i});
});
});
}
});
Loading
Loading