From cdae2da74c9e0333015ba1a6eb9cba5593888941 Mon Sep 17 00:00:00 2001 From: Ryan Roemer Date: Mon, 14 Sep 2026 14:53:20 -0700 Subject: [PATCH 01/23] chore: remove dead tooling, Storybook, and the duplicate demo app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops infrastructure that no longer carries its weight: - `.sail.yml` — the last Sail vestige. Its install/lint/test/prettier/build flow is fully covered by GitHub Actions, and the `prettier` task it referenced was never defined in its own `tasks:` block. - `packages/demo` — never deployed and not referenced by the website, whose `src/components/live-edit.tsx` it duplicates almost verbatim. Its Vite config had no alias to library source, so it resolved `react-live` to built output and was not even a live-source dev loop. - Storybook 6 (EOL) and its two stories — the only consumers of `@storybook/*`, `webpack`, `babel-loader`, and `styled-components@4.0.0-beta`. - `.eslintrc`, `.eslintignore`, `.babelrc`, `tsup.config.ts`, and the website's `babel.config.js`, all replaced in the following commits. - `.npmignore`, obsolete since the `files` field governs the tarball. --- .eslintignore | 3 - .eslintrc | 69 ---- .github/actions/setup/action.yml | 23 -- .npmignore | 2 - .sail.yml | 33 -- packages/demo/index.html | 12 - packages/demo/package.json | 32 -- packages/demo/postcss.config.cjs | 6 - packages/demo/src/app.tsx | 27 -- packages/demo/src/index.css | 3 - packages/demo/src/main.tsx | 10 - packages/demo/src/vite-env.d.ts | 1 - packages/demo/tailwind.config.cjs | 11 - packages/demo/tsconfig.json | 23 -- packages/demo/vite.config.ts | 6 - packages/react-live/.babelrc | 25 -- packages/react-live/.storybook/.babelrc | 12 - packages/react-live/.storybook/main.js | 20 - packages/react-live/.storybook/preview.js | 8 - .../components/Live/LiveEditor.stories.tsx | 33 -- .../components/Live/LiveProvider.stories.tsx | 383 ------------------ packages/react-live/tsup.config.ts | 11 - website/babel.config.js | 3 - 23 files changed, 756 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc delete mode 100644 .github/actions/setup/action.yml delete mode 100644 .npmignore delete mode 100644 .sail.yml delete mode 100644 packages/demo/index.html delete mode 100644 packages/demo/package.json delete mode 100644 packages/demo/postcss.config.cjs delete mode 100644 packages/demo/src/app.tsx delete mode 100644 packages/demo/src/index.css delete mode 100644 packages/demo/src/main.tsx delete mode 100644 packages/demo/src/vite-env.d.ts delete mode 100644 packages/demo/tailwind.config.cjs delete mode 100644 packages/demo/tsconfig.json delete mode 100644 packages/demo/vite.config.ts delete mode 100644 packages/react-live/.babelrc delete mode 100644 packages/react-live/.storybook/.babelrc delete mode 100644 packages/react-live/.storybook/main.js delete mode 100644 packages/react-live/.storybook/preview.js delete mode 100644 packages/react-live/src/components/Live/LiveEditor.stories.tsx delete mode 100644 packages/react-live/src/components/Live/LiveProvider.stories.tsx delete mode 100644 packages/react-live/tsup.config.ts delete mode 100644 website/babel.config.js diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 607e9ccf..00000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -packages/react-live/dist/ -website/build/ diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 703c3a59..00000000 --- a/.eslintrc +++ /dev/null @@ -1,69 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "plugins": ["prettier", "@typescript-eslint"], - "settings": { - "react": { - "version": "detect" - } - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended", - "plugin:import/recommended", - "plugin:import/typescript" - ], - "env": { - "browser": true, - "commonjs": true, - "es6": true, - "node": true, - "jest": true - }, - "globals": { - "expect": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true, - "generators": true, - "experimentalObjectRestSpread": true - }, - "requireConfigFile": false - }, - "rules": { - "comma-dangle": "off", - "indent": "off", - "space-before-function-paren": "off", - "react/jsx-indent-props": "off", - "max-len": "off", - "no-magic-numbers": "off", - "func-style": "off", - "arrow-parens": "off", - "no-use-before-define": "off", - "no-undef": "off", - "react/jsx-filename-extension": "off", - "react/require-extension": "off", - "react/no-multi-comp": "off", - "react/prop-types": "warn", - "react/sort-comp": "warn", - "react/sort-prop-types": "warn", - "react/jsx-handler-names": "off", - "react/no-find-dom-node": "off", - "no-invalid-this": "off", - "complexity": "off", - "no-unused-vars": 0, - "import/no-unresolved": [2, { "ignore": ["polished", "next/document"] }], - "prefer-template": "off", - "filenames/match-regex": "off", - "react/react-in-jsx-scope": "off", - "max-params": ["error", 5], - "max-nested-callbacks": ["error", 5], - "prettier/prettier": "error", - "@typescript-eslint/no-unused-vars": ["error"], - "@typescript-eslint/no-var-requires": 0 - } -} diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml deleted file mode 100644 index 67a4324e..00000000 --- a/.github/actions/setup/action.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Setup -description: Setup Build Step -inputs: - node-version: - required: true - default: '18.x' - -runs: - using: "composite" - steps: - - uses: pnpm/action-setup@v3 - with: - version: 7 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: 'pnpm' - - - name: Install dependencies - shell: bash - run: pnpm install diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 1fd3be27..00000000 --- a/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -src/**/*.test.js -lib/**/*.test.js diff --git a/.sail.yml b/.sail.yml deleted file mode 100644 index 3c79272a..00000000 --- a/.sail.yml +++ /dev/null @@ -1,33 +0,0 @@ -tasks: - install: - image: node:carbon - command: - - yarn - args: - - install - lint: - image: node:carbon - command: - - yarn - args: - - lint - test: - image: node:carbon - command: - - yarn - args: - - test - build: - image: node:carbon - command: - - yarn - args: - - build - -workflow: - - install - - sail:parallel: - - lint - - test - - prettier - - build diff --git a/packages/demo/index.html b/packages/demo/index.html deleted file mode 100644 index 7e6449b6..00000000 --- a/packages/demo/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Vite + React + TS - - -
- - - diff --git a/packages/demo/package.json b/packages/demo/package.json deleted file mode 100644 index 5809ba59..00000000 --- a/packages/demo/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-live": "*" - }, - "devDependencies": { - "@types/react": "^18.0.31", - "@types/react-dom": "^18.0.11", - "@typescript-eslint/eslint-plugin": "^5.57.1", - "@typescript-eslint/parser": "^5.57.1", - "@vitejs/plugin-react": "^4.0.0", - "autoprefixer": "^10.4.14", - "eslint": "^8.38.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.3.4", - "postcss": "^8.4.21", - "tailwindcss": "^3.2.7", - "typescript": "^4.9.5", - "vite": "^4.3.2" - } -} diff --git a/packages/demo/postcss.config.cjs b/packages/demo/postcss.config.cjs deleted file mode 100644 index 33ad091d..00000000 --- a/packages/demo/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/packages/demo/src/app.tsx b/packages/demo/src/app.tsx deleted file mode 100644 index 2c85ddc9..00000000 --- a/packages/demo/src/app.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { LiveProvider, LiveEditor, LivePreview, LiveError } from "react-live"; - -const code = ` -function handleClick() { - console.log("Hi there!"); -} -const HelloWorld = () => ( - -); -render(); -`.trim(); - -export const DemoApp = () => { - return ( -
- -
- - -
- -
-
- ); -}; diff --git a/packages/demo/src/index.css b/packages/demo/src/index.css deleted file mode 100644 index b5c61c95..00000000 --- a/packages/demo/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/packages/demo/src/main.tsx b/packages/demo/src/main.tsx deleted file mode 100644 index f4485e13..00000000 --- a/packages/demo/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import "./index.css"; -import { DemoApp } from "./app"; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - -); diff --git a/packages/demo/src/vite-env.d.ts b/packages/demo/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/packages/demo/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/packages/demo/tailwind.config.cjs b/packages/demo/tailwind.config.cjs deleted file mode 100644 index 74a9791c..00000000 --- a/packages/demo/tailwind.config.cjs +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/packages/demo/tsconfig.json b/packages/demo/tsconfig.json deleted file mode 100644 index 8c8ba5e4..00000000 --- a/packages/demo/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], -} diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts deleted file mode 100644 index 081c8d9f..00000000 --- a/packages/demo/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], -}); diff --git a/packages/react-live/.babelrc b/packages/react-live/.babelrc deleted file mode 100644 index fc257ba8..00000000 --- a/packages/react-live/.babelrc +++ /dev/null @@ -1,25 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "loose": true - } - ], - "@babel/preset-typescript", - ["@babel/preset-react", { "runtime": "automatic" }] - ], - "plugins": [ - "add-module-exports", - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-private-property-in-object", - "@babel/plugin-proposal-private-methods", - [ - "transform-react-remove-prop-types", - { - "mode": "wrap" - } - ] - ] -} diff --git a/packages/react-live/.storybook/.babelrc b/packages/react-live/.storybook/.babelrc deleted file mode 100644 index 565c3227..00000000 --- a/packages/react-live/.storybook/.babelrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "presets": [ - "@babel/preset-env", - "@babel/preset-typescript", - ["@babel/preset-react", { "runtime": "automatic" }] - ], - "plugins": [ - "add-module-exports", - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-proposal-class-properties" - ] -} diff --git a/packages/react-live/.storybook/main.js b/packages/react-live/.storybook/main.js deleted file mode 100644 index b1838361..00000000 --- a/packages/react-live/.storybook/main.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = { - "stories": [ - "../src/**/*.stories.@(js|jsx|ts|tsx)" - ], - "addons": [ - "@storybook/addon-controls", - // "@storybook/addon-actions" - ], - core: { - builder: 'webpack5', - }, - "webpackFinal": async (config) => { - config.module.rules.push({ - test: /\.mjs$/, - include: /node_modules/, - type: "javascript/auto", - }) - return config - } -} diff --git a/packages/react-live/.storybook/preview.js b/packages/react-live/.storybook/preview.js deleted file mode 100644 index 701a9123..00000000 --- a/packages/react-live/.storybook/preview.js +++ /dev/null @@ -1,8 +0,0 @@ -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - // controls: { - // matchers: { - // color: /(background|color)$/i, - // }, - // }, -} \ No newline at end of file diff --git a/packages/react-live/src/components/Live/LiveEditor.stories.tsx b/packages/react-live/src/components/Live/LiveEditor.stories.tsx deleted file mode 100644 index e7d7ee2d..00000000 --- a/packages/react-live/src/components/Live/LiveEditor.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Editor } from "../../index"; -import type { Story } from "@storybook/react"; - -export default { - title: "Editor", - component: Editor, -}; - -const Template = (args: typeof defaultArgs) => ; - -const defaultArgs = { - language: "js", - code: "const x = 'Hello World!';", -}; - -export const Default: Story = Template.bind({}); -Default.args = defaultArgs; - -export const FontFamilyExample: Story = Template.bind( - {} -); -const fontFamilyArgs = { - ...defaultArgs, - style: { - fontFamily: "Monaco", - }, -}; -FontFamilyExample.args = fontFamilyArgs; - -// Can't pass Prism as an arg since it is not JSON-serializable -export const PrismFromNpm = () => ( - -); diff --git a/packages/react-live/src/components/Live/LiveProvider.stories.tsx b/packages/react-live/src/components/Live/LiveProvider.stories.tsx deleted file mode 100644 index ddfbc099..00000000 --- a/packages/react-live/src/components/Live/LiveProvider.stories.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import React, { ChangeEvent } from "react"; -import styled from "styled-components"; -import type { Story } from "@storybook/react"; - -import { - LiveProvider, - LiveEditor, - LiveError, - LivePreview, - withLive, -} from "../../index"; -import { themes } from "prism-react-renderer"; - -const code = ` - - Hello World! - Next Indent Level - -`.trim(); - -const functionExample = ` -() => ( -

- So functional. Much wow! -

-) -`.trim(); - -const functionNoInlineExample = ` - const Counter = () => { - const [count, setCount] = React.useState(0) - return ( -
-

- Counter: {count} 🧮 -

- -
- ) - } - render() -`; - -const componentExample = ` -class Counter extends React.Component { - constructor() { - super() - this.state = { count: 0 } - } - - componentDidMount() { - this.interval = setInterval(() => { - this.setState(state => ({ count: state.count + 1 })) - }, 1000) - } - - componentWillUnmount() { - clearInterval(this.interval) - } - - render() { - return ( -
-

- {this.state.count} -

-
- ) - } -} -`.trim(); - -const tsComponentExample = ` -interface CounterElement extends JSX.Element {} - -class Counter extends React.Component<{}> { - constructor() { - super() - this.state = { count: 0 } - } - - componentDidMount() { - this.interval = setInterval(() => { - this.setState(state => ({ count: state.count + 1 })) - }, 1000) - } - - componentWillUnmount() { - clearInterval(this.interval) - } - - render(): CounterElement { - return ( -
-

- {this.state.count} -

-
- ) - } -} -`.trim(); - -const hooksExample = ` -function LikeButton() { - const [likes, increaseLikes] = React.useState(0) - - return ( - <> -

{likes} likes

- + + ) + }`, + }); + + expect(await screen.findByText("0 likes")).toBeDefined(); + await user.click(screen.getByRole("button", { name: "like" })); + expect(await screen.findByText("1 likes")).toBeDefined(); + }); - render(); + it("requires `render` to be called when noInline is set", async () => { + renderLive({ + noInline: true, + code: `const Hi = () =>

No inline

+ render()`, + }); + expect( + await screen.findByRole("heading", { name: "No inline" }), + ).toBeDefined(); + }); + + it("reports an error when noInline code never calls render", async () => { + renderLive({ noInline: true, code: "

nope

" }); + expect( + await screen.findByText(/No-Inline evaluations must call `render`/), + ).toBeDefined(); + }); + + it("exposes values passed via scope", async () => { + renderLive({ + code: "{greeting}", + scope: { greeting: "hello from scope" }, + }); + expect(await screen.findByText("hello from scope")).toBeDefined(); + }); + + it("re-transpiles when the code prop changes", async () => { + const { rerender } = render( + + + , + ); + expect(await screen.findByRole("heading", { name: "first" })).toBeDefined(); - return waitAsync().then(() => { - expect(renderElementAsync).toHaveBeenCalledTimes(1); - expect(renderElementAsync.mock.calls[0][0].code).toBe( - "render(
hello
)", + rerender( + + + , ); + expect( + await screen.findByRole("heading", { name: "second" }), + ).toBeDefined(); }); }); -function ErrorRenderer() { - const { error } = useContext(LiveContext); - return
{error?.message}
; -} +describe("TypeScript", () => { + const tsCode = `const greet = (name: string): string => \`Hi \${name}\` + render(

{greet("TS")}

)`; + + it("strips TypeScript syntax by default", async () => { + renderLive({ code: tsCode, noInline: true }); + expect(await screen.findByRole("heading", { name: "Hi TS" })).toBeDefined(); + }); -it.skip("catches errors from a synchronous transformCode function", () => { - function transformCode() { - throw new Error("testError"); - } + it("errors on TypeScript syntax when enableTypeScript is false", async () => { + renderLive({ code: tsCode, noInline: true, enableTypeScript: false }); + expect(await screen.findByTestId("live-error")).toBeDefined(); + }); +}); - const wrapper = render( - - - , - ); +describe("errors", () => { + it("surfaces a syntax error", async () => { + renderLive({ code: "
" }); + expect(await screen.findByTestId("live-error")).toBeDefined(); + }); - return waitAsync().then(() => { - expect(renderElementAsync).not.toHaveBeenCalled(); + it("recovers once the code becomes valid again", async () => { + const { rerender } = render( + + + + , + ); + expect(await screen.findByTestId("live-error")).toBeDefined(); - const handledErrorWrapper = wrapper.find('[data-testid="handledError"]'); - expect(handledErrorWrapper.text()).toBe("testError"); + rerender( + + + + , + ); + expect(await screen.findByRole("heading", { name: "fixed" })).toBeDefined(); + }); + + it("renders nothing when there is no error", async () => { + render( + + + + , + ); + // Wait for the successful render before asserting the absence of an error, + // otherwise this passes simply because nothing has happened yet. + await screen.findByRole("heading", { name: "fine" }); + expect(screen.queryByTestId("live-error")).toBeNull(); }); }); -it.skip("catches errors from an asynchronous transformCode function", () => { - function transformCode() { - return Promise.reject(new Error("testError")); - } +/** + * The previous versions of these four tests asserted nothing: they chained off + * React's `act()` thenable, which is not a real Promise, so the runner never + * awaited them and the callbacks ran after the test had already passed. + */ +describe("transformCode", () => { + it("applies a synchronous transformCode function", async () => { + renderLive({ + code: "hello", + noInline: true, + transformCode: (code) => `render(
${code}
)`, + }); + expect(await screen.findByText("hello")).toBeDefined(); + }); - const wrapper = render( - - - , - ); + it("applies an asynchronous transformCode function", async () => { + renderLive({ + code: "hello", + noInline: true, + transformCode: (code) => Promise.resolve(`render(
${code}
)`), + }); + expect(await screen.findByText("hello")).toBeDefined(); + }); - return waitAsync().then(() => { - expect(renderElementAsync).not.toHaveBeenCalled(); + it("catches errors from a synchronous transformCode function", async () => { + renderLive({ + code: "hello", + noInline: true, + transformCode: () => { + throw new Error("testError"); + }, + }); + expect(await screen.findByText(/testError/)).toBeDefined(); + }); - const handledErrorWrapper = wrapper.find('[data-testid="handledError"]'); - expect(handledErrorWrapper.text()).toBe("testError"); + it("catches errors from an asynchronous transformCode function", async () => { + renderLive({ + code: "hello", + noInline: true, + transformCode: () => Promise.reject(new Error("testError")), + }); + expect(await screen.findByText(/testError/)).toBeDefined(); }); }); diff --git a/packages/react-live/src/components/Live/live-components.test.jsx b/packages/react-live/src/components/Live/live-components.test.jsx new file mode 100644 index 00000000..27a849c6 --- /dev/null +++ b/packages/react-live/src/components/Live/live-components.test.jsx @@ -0,0 +1,104 @@ +import { act, render, screen } from "@testing-library/react"; + +import LiveProvider from "./LiveProvider"; +import LivePreview from "./LivePreview"; +import LiveEditor from "./LiveEditor"; +import withLive from "../../hoc/withLive"; + +// LiveProvider transpiles inside an effect. Tests that assert synchronously +// finish before that promise resolves, so the resulting state update lands +// outside act() and React warns. Let it settle first. +const settle = () => act(async () => {}); + +describe("LivePreview", () => { + it("wraps the preview in a div by default", async () => { + const { container } = render( + + + , + ); + expect(await screen.findByText("inner")).toBeDefined(); + expect(container.querySelector("div")).toBeDefined(); + }); + + it("honours the Component prop", async () => { + const { container } = render( + + + , + ); + await screen.findByText("inner"); + expect(container.querySelector("section")).not.toBeNull(); + }); + + it("forwards extra props to the wrapper element", async () => { + render( + + + , + ); + const preview = await screen.findByTestId("preview"); + expect(preview.className).toBe("wrapper"); + }); +}); + +describe("LiveEditor", () => { + it("renders the provider's code", async () => { + const { container } = render( + + + , + ); + await settle(); + expect(container.textContent).toContain("fromProvider"); + }); + + it("lets explicit props override the context", async () => { + const { container } = render( + + + , + ); + await settle(); + expect(container.textContent).toContain("override"); + expect(container.textContent).not.toContain("fromProvider"); + }); +}); + +describe("withLive", () => { + it("injects the live context as a `live` prop", async () => { + const Consumer = withLive(({ live }) => ( +
{live.code}
+ )); + const Wrapped = () => ( + + + + ); + + render(); + expect((await screen.findByTestId("consumer")).textContent).toBe( + "hi", + ); + }); + + it("exposes the transpiled element through context", async () => { + const Consumer = withLive(({ live }) => { + const Result = live.element; + return Result ? : null; + }); + + render( + + + , + ); + expect( + await screen.findByRole("heading", { name: "from context" }), + ).toBeDefined(); + }); + + it("sets a displayName", () => { + expect(withLive(() => null).displayName).toBe("WithLive"); + }); +}); diff --git a/packages/react-live/vitest.config.mts b/packages/react-live/vitest.config.mts index 9a7e56c7..e83353de 100644 --- a/packages/react-live/vitest.config.mts +++ b/packages/react-live/vitest.config.mts @@ -4,10 +4,23 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", + // Matches Jest's `resetMocks: true` (vitest's `restoreMocks` would + // reinstate real implementations and defeat `vi.mock`). mockReset: true, // The suite predates Jest 29's snapshot format change; keep the legacy // escaping so the existing inline snapshots stay valid. snapshotFormat: { escapeString: true, printBasicPrototype: true }, include: ["src/**/*.test.{js,jsx,ts,tsx}"], + setupFiles: ["./vitest.setup.js"], + // The default reporter hides console output from *passing* test files, so + // warnings printed by a green run are invisible. Be verbose in CI, where + // nobody is watching a live terminal and the log is the only record. + reporters: process.env.CI ? ["verbose"] : ["default"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**"], + exclude: ["src/**/*.test.*", "src/utils/test/**"], + }, }, }); diff --git a/packages/react-live/vitest.setup.js b/packages/react-live/vitest.setup.js new file mode 100644 index 00000000..bd9df590 --- /dev/null +++ b/packages/react-live/vitest.setup.js @@ -0,0 +1,31 @@ +import { afterEach } from "vitest"; + +/** + * Fail on React's act() warning instead of printing it. + * + * "An update to X inside a test was not wrapped in act(...)" means a test + * asserted while a state update was still in flight -- the assertion ran + * against a half-settled tree and may be passing for the wrong reason. That is + * a broken test, not a cosmetic warning. + * + * It is easy to miss otherwise: vitest's default reporter hides console output + * from *passing* test files, so these only appear under --reporter=verbose. + */ +const actWarnings = []; +const originalError = console.error; + +console.error = (...args) => { + if (String(args[0] ?? "").includes("not wrapped in act")) { + actWarnings.push(String(args[0]).split("\n")[0]); + return; + } + originalError(...args); +}; + +afterEach(() => { + if (actWarnings.length === 0) return; + const seen = actWarnings.splice(0); + throw new Error( + `React act() warning -- assert after state settles (e.g. await a findBy* query):\n ${seen.join("\n ")}`, + ); +}); diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index a91d845f..fe46dc3b 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -33,7 +33,7 @@ export default function Index() { Date: Mon, 14 Sep 2026 21:11:06 -0700 Subject: [PATCH 08/23] Testing fixups and storybook manual harness --- .github/workflows/ci.yml | 22 ++ .gitignore | 1 + CONTRIBUTING.MD | 71 ++++- package-lock.json | 242 ++++++++++++++++++ package.json | 4 +- packages/react-live/package.json | 6 + .../Live/editor-interaction.browser.test.jsx | 74 ++++++ .../react-live/stories/editor.stories.jsx | 52 ++++ packages/react-live/stories/index.html | 12 + packages/react-live/stories/live.stories.jsx | 182 +++++++++++++ packages/react-live/stories/main.jsx | 88 +++++++ .../stories/stories.browser.test.jsx | 37 +++ packages/react-live/vite.stories.mts | 15 ++ packages/react-live/vitest.browser.mts | 29 +++ packages/react-live/vitest.config.mts | 3 + 15 files changed, 823 insertions(+), 15 deletions(-) create mode 100644 packages/react-live/src/components/Live/editor-interaction.browser.test.jsx create mode 100644 packages/react-live/stories/editor.stories.jsx create mode 100644 packages/react-live/stories/index.html create mode 100644 packages/react-live/stories/live.stories.jsx create mode 100644 packages/react-live/stories/main.jsx create mode 100644 packages/react-live/stories/stories.browser.test.jsx create mode 100644 packages/react-live/vite.stories.mts create mode 100644 packages/react-live/vitest.browser.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad0984f..3f7369d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,25 @@ jobs: # being built out. Watch the number rather than blocking PRs on it. - name: Coverage run: npm run test:coverage -w react-live + + browser: + name: Browser tests + runs-on: ubuntu-latest + permissions: + contents: read # to check out repo (actions/checkout) + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "npm" + - run: npm ci + # Separate job: these need a real Chromium, which jsdom cannot stand in + # for (no contentEditable editing model). Kept out of `npm run check` so + # the main job does not pay the browser download. + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Run browser tests + run: npm run test:browser diff --git a/.gitignore b/.gitignore index ce865a5f..4aac2a37 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist build coverage +.vitest .docusaurus .out .next diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.MD index 9962cfdd..d51a8242 100644 --- a/CONTRIBUTING.MD +++ b/CONTRIBUTING.MD @@ -47,6 +47,8 @@ All of these run from the repo root: | `npm run build` | Build the library, then the website. | | `npm run build:lib` | Build only the library. | | `npm run start:docs` | Run the docs site locally. | +| `npm run stories` | Browse component scenarios in a dev server. | +| `npm run test:browser` | Real-browser tests (needs Chromium, see below). | **Nothing builds the library implicitly.** There is no `prepare` or `postinstall` hook, so `npm install` never triggers a build. The website imports `react-live` and resolves it to @@ -105,27 +107,68 @@ jsdom. Many of these scenarios are ported from the Storybook stories that used t alongside the components -- Storybook was never wired into CI, so they had only ever been checked by eye. -**jsdom has no contentEditable editing model**, which sets a hard boundary on the `Editor` -component. Rendering, syntax highlighting, theming, and configuration are covered; actual -typing is not, because simulated key events do not produce input on a contentEditable -element in jsdom. That leaves these untested: +**jsdom has no contentEditable editing model**, so anything involving typing or caret +position cannot run there. Those tests live in a separate real-browser suite -- see +[Stories and browser tests](#stories-and-browser-tests). In jsdom the `Editor` covers +rendering, syntax highlighting, theming, and configuration. -- typing into the editor (the `useEditable` `onChange` path) -- caret position after Enter -- note there is a past fix, "Fix cursor position on return key - press", with no regression test -- `tabMode` of `"focus"` versus `"indentation"` +### Stories and browser tests -Covering those needs a real browser. [Vitest browser mode](https://vitest.dev/guide/browser/) -with Playwright does work for this -- a proof-of-concept confirmed that real typing fires -`onChange` -- but caret assertions take real care to write, so it has not been set up yet. +There is no Storybook. Instead there is a ~110 line harness that does the two things we +actually used it for, from one set of story definitions. + +```sh +npm run stories # dev server: browse scenarios by hand +npm run test:browser # the same stories + interaction tests, in real Chromium +``` + +Stories live in [`packages/react-live/stories`](packages/react-live/stories) as +`*.stories.jsx`. A story is just an object: + +```jsx +export const title = "Live"; + +export const Inline = { + args: { code: "Hello World!" }, + render: (args) => , +}; +``` + +Add a file matching `*.stories.jsx` and it appears in the sidebar automatically -- +`stories/main.jsx` discovers them with `import.meta.glob`. The dev server aliases +`react-live` to source, so library edits hot-reload with no build step. +`?story=` selects a story; `?only=1` renders it without the surrounding chrome. + +**The same modules are the tests.** `stories/stories.browser.test.jsx` uses the identical +glob and smoke-renders every story, so a story that throws fails CI. Interaction tests that +need real typing live alongside the components as `*.browser.test.jsx`. + +Browser tests need a Chromium binary: + +```sh +npx playwright install chromium +``` + +They are deliberately **not** part of `npm run check` -- they run in their own CI job, so the +main job does not pay for the browser download. Config is in +[`vitest.browser.mts`](packages/react-live/vitest.browser.mts); the jsdom config excludes +`*.browser.test.*` so the two suites never overlap. + +One gotcha if you write caret tests: assert **behaviourally** -- type a character and check +where it lands -- rather than reading `Selection` offsets. Prism splits each line into many +token spans plus a trailing newline node, so raw offsets do not mean what you would expect. #### Why not Storybook? Storybook 10 is a large improvement on the v6 that was removed, and `@storybook/react-vite` supports Vite 8. Its test integration, `@storybook/addon-vitest`, would turn stories into -real browser tests -- but it peers on `vitest@^3 || ^4`, and this repo is on Vitest 5. Until -that catches up, Storybook would cost ~156 packages without being able to run the tests. -Worth revisiting when the peer range moves. +real browser tests -- but it peers on `vitest@^3 || ^4`, and this repo is on Vitest 5. It +would cost ~156 packages and still not run the tests; the harness above costs 15 and does. + +What Storybook would add on top: a controls/args panel, autodocs generated from TypeScript +types, the addon ecosystem (a11y, viewport, interaction debugger), and Chromatic-style +visual regression. If any of those start to matter, revisit -- especially once the +`addon-vitest` peer range moves. ### Linting and formatting diff --git a/package-lock.json b/package-lock.json index 939c154d..e3530a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2112,6 +2112,13 @@ "node": ">=18" } }, + "node_modules/@blazediff/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz", + "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@braidai/lang": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", @@ -9125,6 +9132,121 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/browser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-5.0.0.tgz", + "integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.10.0", + "@vitest/mocker": "5.0.0", + "@vitest/ui": "5.0.0", + "@vitest/utils": "5.0.0", + "magic-string": "^1.2.3", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1", + "ws": "^8.21.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz", + "integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "5.0.0", + "@vitest/mocker": "5.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "5.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/browser/node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", @@ -9205,6 +9327,19 @@ } } }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.0.tgz", + "integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/spy": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", @@ -9215,6 +9350,57 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-5.0.0.tgz", + "integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "5.0.0", + "fflate": "^0.8.3", + "flatted": "^3.4.4", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/ui/node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.0.tgz", + "integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -13028,6 +13214,13 @@ "flat": "cli.js" } }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -18454,6 +18647,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -18525,6 +18725,45 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -24851,8 +25090,11 @@ "@testing-library/user-event": "^14.6.7", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/browser-playwright": "^5.0.0", "@vitest/coverage-v8": "^5.0.0", "jsdom": "^30.0.1", + "playwright": "^1.63.0", "publint": "^0.3.24", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/package.json b/package.json index 15de3258..003b7378 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "check:types": "npm run typecheck --workspaces --if-present", "format": "prettier --write . && oxlint --fix", "start:docs": "npm run start -w website", - "test": "npm test -w react-live" + "stories": "npm run stories -w react-live", + "test": "npm test -w react-live", + "test:browser": "npm run test:browser -w react-live" }, "devDependencies": { "@changesets/changelog-github": "^1.0.1", diff --git a/packages/react-live/package.json b/packages/react-live/package.json index 0de474fc..0889415a 100644 --- a/packages/react-live/package.json +++ b/packages/react-live/package.json @@ -43,7 +43,10 @@ "build": "tsdown", "build:watch": "tsdown --watch", "prepack": "cp ../../README.md ../../LICENSE . && npm run build", + "stories": "vite --config vite.stories.mts", + "stories:build": "vite build --config vite.stories.mts", "test": "vitest run", + "test:browser": "vitest run -c vitest.browser.mts", "test:coverage": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --noEmit" @@ -63,8 +66,11 @@ "@testing-library/user-event": "^14.6.7", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/browser-playwright": "^5.0.0", "@vitest/coverage-v8": "^5.0.0", "jsdom": "^30.0.1", + "playwright": "^1.63.0", "publint": "^0.3.24", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/react-live/src/components/Live/editor-interaction.browser.test.jsx b/packages/react-live/src/components/Live/editor-interaction.browser.test.jsx new file mode 100644 index 00000000..37dc4001 --- /dev/null +++ b/packages/react-live/src/components/Live/editor-interaction.browser.test.jsx @@ -0,0 +1,74 @@ +import { render } from "@testing-library/react"; +import { userEvent } from "vitest/browser"; +import { describe, expect, it, vi } from "vitest"; + +import LiveProvider from "./LiveProvider"; +import LiveEditor from "./LiveEditor"; + +/** + * Editing behaviour, in a real browser. + * + * jsdom cannot cover any of this: it has no contentEditable editing model, so + * synthesised key events never produce input. See the jsdom-side + * `Editor/index.test.jsx` for rendering, highlighting, and theming. + * + * Caret position is asserted *behaviourally* -- type a character and see where + * it lands. Reading `Selection` offsets directly is meaningless here, because + * Prism splits every line into many token spans plus a trailing newline node. + */ +const setup = async (code) => { + const onChange = vi.fn(); + const { container } = render( + + + , + ); + const pre = container.querySelector("pre"); + await userEvent.click(pre); + return { pre, onChange, text: () => pre.textContent }; +}; + +describe("editor interaction", () => { + it("types characters into the editor", async () => { + const { text } = await setup("ab"); + await userEvent.keyboard("XYZ"); + expect(text()).toContain("abXYZ"); + }); + + it("leaves the caret at the click position", async () => { + const { text } = await setup("abc"); + await userEvent.keyboard("Z"); + expect(text()).toContain("abcZ"); + }); + + it("honours Home to jump to the start of the line", async () => { + const { text } = await setup("abc"); + await userEvent.keyboard("{Home}Z"); + expect(text()).toContain("Zabc"); + }); + + /** + * Regression test for "Fix cursor position on return key press" (#392 era), + * which shipped without one. If the caret does not move to the new line, + * the typed character is appended to line 1 instead. + */ + it("moves the caret to the new line after Enter", async () => { + const { text } = await setup("abc"); + await userEvent.keyboard("{Enter}z"); + expect(text()).toContain("abc\nz"); + expect(text()).not.toContain("abcz"); + }); + + it("keeps typing on the new line after Enter", async () => { + const { text } = await setup("first"); + await userEvent.keyboard("{Enter}second"); + expect(text()).toContain("first\nsecond"); + }); + + it("reports each edit through onChange", async () => { + const { onChange } = await setup("a"); + await userEvent.keyboard("bc"); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls.at(-1)[0]).toContain("abc"); + }); +}); diff --git a/packages/react-live/stories/editor.stories.jsx b/packages/react-live/stories/editor.stories.jsx new file mode 100644 index 00000000..b059028f --- /dev/null +++ b/packages/react-live/stories/editor.stories.jsx @@ -0,0 +1,52 @@ +import { themes } from "prism-react-renderer"; +import { Editor } from "react-live"; + +export const title = "Editor"; + +export const Default = { + args: { code: "const greeting = 'Hello World!';", language: "javascript" }, + render: (args) => , +}; + +export const MultiLine = { + args: { + code: "function add(a, b) {\n return a + b;\n}\n\nadd(1, 2);", + language: "javascript", + }, + render: (args) => , +}; + +export const TypeScript = { + args: { + code: "const add = (a: number, b: number): number => a + b;", + language: "typescript", + }, + render: (args) => , +}; + +export const CustomFontFamily = { + args: { + code: "const greeting = 'Hello World!';", + language: "javascript", + style: { fontFamily: "'Courier New', monospace", fontSize: 18 }, + }, + render: (args) => , +}; + +export const LightTheme = { + args: { + code: "const greeting = 'Hello World!';", + language: "javascript", + theme: themes.github, + }, + render: (args) => , +}; + +export const Disabled = { + args: { + code: "// read only", + language: "javascript", + disabled: true, + }, + render: (args) => , +}; diff --git a/packages/react-live/stories/index.html b/packages/react-live/stories/index.html new file mode 100644 index 00000000..b8829b84 --- /dev/null +++ b/packages/react-live/stories/index.html @@ -0,0 +1,12 @@ + + + + + + react-live stories + + +
+ + + diff --git a/packages/react-live/stories/live.stories.jsx b/packages/react-live/stories/live.stories.jsx new file mode 100644 index 00000000..f10d8368 --- /dev/null +++ b/packages/react-live/stories/live.stories.jsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { themes } from "prism-react-renderer"; +import { + LiveProvider, + LiveEditor, + LiveError, + LivePreview, + withLive, +} from "react-live"; + +export const title = "Live"; + +/** The standard editor + preview + error layout. */ +const Playground = (props) => ( + + + + + +); + +export const Inline = { + args: { code: "\n Hello World!\n Next Indent Level\n" }, + render: (args) => , +}; + +export const FunctionComponent = { + args: { code: "() => (\n

\n So functional. Much wow!\n

\n)" }, + render: (args) => , +}; + +export const NoInline = { + args: { + noInline: true, + code: `const Counter = () => { + const [count, setCount] = React.useState(0) + return ( +
+

Counter: {count}

+ +
+ ) +} +render()`, + }, + render: (args) => , +}; + +export const ClassComponent = { + args: { + code: `class Counter extends React.Component { + constructor() { + super() + this.state = { count: 0 } + } + render() { + return ( + + ) + } +}`, + }, + render: (args) => , +}; + +export const TypeScriptComponent = { + args: { + noInline: true, + code: `interface Props { name: string } + +const Greeting = ({ name }: Props) =>

Hello {name}

+ +render()`, + }, + render: (args) => , +}; + +export const TypeScriptDisabled = { + args: { + enableTypeScript: false, + noInline: true, + code: `const greet = (name: string) => name +render(

{greet("this should error")}

)`, + }, + render: (args) => , +}; + +export const Hooks = { + args: { + code: `function LikeButton() { + const [likes, increaseLikes] = React.useState(0) + return ( +
+

{likes} likes

+ +
+ ) +}`, + }, + render: (args) => , +}; + +export const WithScope = { + args: { + code: "

{greeting} from scope

", + scope: { greeting: "Hello" }, + }, + render: (args) => , +}; + +export const WithTheme = { + args: { + code: "Hello World!", + theme: themes.github, + }, + render: (args) => , +}; + +export const SyntaxError = { + args: { code: "
" }, + render: (args) => , +}; + +export const RuntimeError = { + args: { code: "() => { throw new Error('boom') }" }, + render: (args) => , +}; + +export const Disabled = { + args: { code: "You cannot edit me", disabled: true }, + render: (args) => , +}; + +export const TransformCode = { + args: { + code: "Hello World!", + noInline: true, + transformCode: (code) => `render(

${code}

)`, + }, + render: (args) => , +}; + +/** A custom editing surface driven by the same provider. */ +export const CustomEditor = { + args: { code: "

Edit me in the textarea

" }, + render: ({ code: initial }) => { + const [code, setCode] = useState(initial); + return ( + +