From 4c8cf7e08b185b8b6386f0b1cd3e245683583a23 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 20:42:38 +0200 Subject: [PATCH 1/3] test: check the built packages in plain Node, and turn the Next.js server-util test on again --- .../src/context/ServerBlockNoteEditor.ts | 4 +- packages/server-util/vitestSetup.ts | 7 +- tests/nextjs-test-app/.gitignore | 1 + tests/nextjs-test-app/package.json | 12 +-- tests/nextjs-test-app/setup.sh | 28 +------ .../unit/builtPackages/builtPackages.test.ts | 69 ++++++++++++++++++ tests/src/unit/builtPackages/loadEntries.mjs | 37 ++++++++++ tests/src/unit/builtPackages/serverExport.mjs | 73 +++++++++++++++++++ tests/src/unit/nextjs/serverUtil.test.ts | 13 ++-- 9 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 tests/src/unit/builtPackages/builtPackages.test.ts create mode 100644 tests/src/unit/builtPackages/loadEntries.mjs create mode 100644 tests/src/unit/builtPackages/serverExport.mjs diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index 21a259f6c0..1d0cdd4452 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -66,8 +66,8 @@ export class ServerBlockNoteEditor< globalThis.document = this.jsdom.window.document; (globalThis as any).window = this.jsdom.window; (globalThis as any).window.__TEST_OPTIONS = ( - prevWindow as any - )?.__TEST_OPTIONS; + (prevWindow as any) ?? globalThis + ).__TEST_OPTIONS; try { return await fn(); } finally { diff --git a/packages/server-util/vitestSetup.ts b/packages/server-util/vitestSetup.ts index dbcf3eb39c..01aee78b34 100644 --- a/packages/server-util/vitestSetup.ts +++ b/packages/server-util/vitestSetup.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// No `window`, as in the Node apps that use this package. The editor reads +// `__TEST_OPTIONS` off `globalThis` when there is no `window`. beforeEach(() => { - globalThis.window = globalThis.window || ({} as any); - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + (globalThis as { __TEST_OPTIONS?: object }).__TEST_OPTIONS = {}; }); afterEach(() => { - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + delete (globalThis as { __TEST_OPTIONS?: object }).__TEST_OPTIONS; }); diff --git a/tests/nextjs-test-app/.gitignore b/tests/nextjs-test-app/.gitignore index 917ab21c9e..a40aad06c8 100644 --- a/tests/nextjs-test-app/.gitignore +++ b/tests/nextjs-test-app/.gitignore @@ -3,3 +3,4 @@ node_modules .tarballs package-lock.json next-env.d.ts +AGENTS.md diff --git a/tests/nextjs-test-app/package.json b/tests/nextjs-test-app/package.json index 65edcec930..d6304b6055 100644 --- a/tests/nextjs-test-app/package.json +++ b/tests/nextjs-test-app/package.json @@ -3,14 +3,16 @@ "private": true, "version": "0.0.0", "dependencies": { - "@blocknote/core": "file:.tarballs/blocknote-core-0.50.0.tgz", - "@blocknote/mantine": "file:.tarballs/blocknote-mantine-0.50.0.tgz", - "@blocknote/react": "file:.tarballs/blocknote-react-0.50.0.tgz", - "@blocknote/server-util": "file:.tarballs/blocknote-server-util-0.50.0.tgz", + "@blocknote/core": "file:.tarballs/blocknote-core.tgz", + "@blocknote/mantine": "file:.tarballs/blocknote-mantine.tgz", + "@blocknote/react": "file:.tarballs/blocknote-react.tgz", + "@blocknote/server-util": "file:.tarballs/blocknote-server-util.tgz", "@mantine/core": "^9.0.2", "@mantine/hooks": "^9.0.2", "next": "^16.0.0", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "y-prosemirror": "^1.3.7", + "yjs": "^13.6.33" } } diff --git a/tests/nextjs-test-app/setup.sh b/tests/nextjs-test-app/setup.sh index 56efd25f95..bde3407890 100755 --- a/tests/nextjs-test-app/setup.sh +++ b/tests/nextjs-test-app/setup.sh @@ -29,33 +29,13 @@ for pkg in core react server-util mantine; do pnpm pack --pack-destination "$TARBALLS_DIR" 2>/dev/null done -# Update package.json to point to tarballs +# Fixed names, so that package.json does not change with the version cd "$TARBALLS_DIR" -CORE_TGZ=$(ls blocknote-core-*.tgz) -REACT_TGZ=$(ls blocknote-react-*.tgz) -SERVER_TGZ=$(ls blocknote-server-util-*.tgz) -MANTINE_TGZ=$(ls blocknote-mantine-*.tgz) +for pkg in core react server-util mantine; do + mv blocknote-$pkg-*.tgz blocknote-$pkg.tgz +done cd "$SCRIPT_DIR" -cat > package.json << EOF -{ - "name": "@blocknote/nextjs-test-app", - "private": true, - "version": "0.0.0", - "dependencies": { - "@blocknote/core": "file:.tarballs/$CORE_TGZ", - "@blocknote/mantine": "file:.tarballs/$MANTINE_TGZ", - "@blocknote/react": "file:.tarballs/$REACT_TGZ", - "@blocknote/server-util": "file:.tarballs/$SERVER_TGZ", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "next": "^16.0.0", - "react": "^19.2.3", - "react-dom": "^19.2.3" - } -} -EOF - # Install with npm (not pnpm — avoid workspace resolution) rm -rf node_modules .next package-lock.json npm install --ignore-scripts diff --git a/tests/src/unit/builtPackages/builtPackages.test.ts b/tests/src/unit/builtPackages/builtPackages.test.ts new file mode 100644 index 0000000000..b47b879646 --- /dev/null +++ b/tests/src/unit/builtPackages/builtPackages.test.ts @@ -0,0 +1,69 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +// These tests check the build output as an app installs it: in plain Node, +// without the aliases to the sources that the other tests use. They cannot find +// a missing dependency, as the workspace installs all of them (see nextjs/). +const packagesDir = resolve(__dirname, "../../../../packages"); + +function publishedPackages() { + return readdirSync(packagesDir).flatMap((dir) => { + const path = join(packagesDir, dir, "package.json"); + if (!existsSync(path)) { + return []; + } + const pkg = JSON.parse(readFileSync(path, "utf8")); + return pkg.private ? [] : [{ dir, pkg }]; + }); +} + +function exportTargets(exports: unknown): string[] { + if (typeof exports === "string") { + return [exports]; + } + if (exports && typeof exports === "object") { + return Object.values(exports).flatMap(exportTargets); + } + return []; +} + +function runNode(script: string, env: Record = {}) { + return execFileSync(process.execPath, [join(__dirname, script)], { + encoding: "utf8", + env: { ...process.env, ...env }, + timeout: 120_000, + }); +} + +// CI runs this after `vp run -r build`. Locally, it waits for a build. +describe.skipIf(!existsSync(join(packagesDir, "core/dist")) && !process.env.CI)( + "Built packages", + () => { + it("point to files that exist", () => { + const missing = publishedPackages().flatMap(({ dir, pkg }) => + [pkg.main, pkg.module, pkg.types, ...exportTargets(pkg.exports)] + .filter((target): target is string => typeof target === "string") + .filter((target) => !existsSync(resolve(packagesDir, dir, target))) + .map((target) => `${pkg.name}: ${target}`), + ); + expect([...new Set(missing)]).toEqual([]); + }); + + it("load in plain Node", { timeout: 60_000 }, () => { + expect(JSON.parse(runNode("loadEntries.mjs"))).toEqual([]); + }); + + // React's development build crashed the process after the export (#3230). + it.each(["development", "production"])( + "export React and optional blocks on a server (NODE_ENV=%s)", + { timeout: 60_000 }, + (nodeEnv) => { + expect(runNode("serverExport.mjs", { NODE_ENV: nodeEnv })).toContain( + "exported", + ); + }, + ); + }, +); diff --git a/tests/src/unit/builtPackages/loadEntries.mjs b/tests/src/unit/builtPackages/loadEntries.mjs new file mode 100644 index 0000000000..0447aa8c52 --- /dev/null +++ b/tests/src/unit/builtPackages/loadEntries.mjs @@ -0,0 +1,37 @@ +// Imports every entry of every published package from its build output, in +// plain Node (no bundler, no Vitest), and prints the entries that fail as JSON. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const packagesDir = resolve(import.meta.dirname, "../../../../packages"); + +const failures = []; +for (const dir of readdirSync(packagesDir)) { + const packageJSONPath = join(packagesDir, dir, "package.json"); + if (!existsSync(packageJSONPath)) { + continue; + } + const pkg = JSON.parse(readFileSync(packageJSONPath, "utf8")); + if (pkg.private || !pkg.exports) { + continue; + } + for (const [subpath, target] of Object.entries(pkg.exports)) { + const file = + typeof target === "string" ? target : (target.import ?? target.default); + // Stylesheets and WebAssembly files are for bundlers, not for Node. + if (!file || !/\.m?js$/.test(file)) { + continue; + } + try { + await import(pathToFileURL(resolve(packagesDir, dir, file)).href); + } catch (error) { + failures.push({ + entry: pkg.name + subpath.slice(1), + error: String(error).split("\n")[0], + }); + } + } +} + +console.log(JSON.stringify(failures)); diff --git a/tests/src/unit/builtPackages/serverExport.mjs b/tests/src/unit/builtPackages/serverExport.mjs new file mode 100644 index 0000000000..5856638537 --- /dev/null +++ b/tests/src/unit/builtPackages/serverExport.mjs @@ -0,0 +1,73 @@ +// Exports a document with React and optional blocks through +// `ServerBlockNoteEditor`, in plain Node from the build output, as a server +// app does. +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const packagesDir = resolve(import.meta.dirname, "../../../../packages"); + +function entry(dir) { + const pkg = JSON.parse( + readFileSync(join(packagesDir, dir, "package.json"), "utf8"), + ); + return pathToFileURL(resolve(packagesDir, dir, pkg.exports["."].import)).href; +} + +const { BlockNoteSchema } = await import(entry("core")); +const { createReactBlockSpec } = await import(entry("react")); +const { ServerBlockNoteEditor } = await import(entry("server-util")); +const { createReactInlineMathSpec, createReactMathBlockSpec } = await import( + entry("math-block") +); +const { createReactDiagramBlockSpec } = await import(entry("diagram-block")); +const { withMultiColumn } = await import(entry("xl-multi-column")); +// The React that `@blocknote/react` uses, so that its hooks work. +const { createElement, useEffect } = createRequire( + join(packagesDir, "react/package.json"), +)("react"); + +const reactBlock = createReactBlockSpec( + { type: "reactBlock", propSchema: {}, content: "inline" }, + { + render: (props) => { + useEffect(() => {}); + return createElement("p", { ref: props.contentRef }); + }, + }, +); + +const schema = withMultiColumn(BlockNoteSchema.create()).extend({ + blockSpecs: { + mathBlock: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + reactBlock: reactBlock(), + }, + inlineContentSpecs: { math: createReactInlineMathSpec() }, +}); + +const blocks = [ + { + type: "paragraph", + content: ["Inline ", { type: "math", content: "x^2" }, " math"], + }, + { type: "mathBlock", content: "a^2 + b^2 = c^2" }, + { type: "diagram", content: "graph TD\n A --> B" }, + { type: "reactBlock", content: "React block" }, + { + type: "columnList", + children: [ + { type: "column", children: [{ type: "paragraph", content: "Left" }] }, + { type: "column", children: [{ type: "paragraph", content: "Right" }] }, + ], + }, +]; + +const editor = ServerBlockNoteEditor.create({ schema }); +await editor.blocksToMarkdownLossy(blocks); +await editor.blocksToHTMLLossy(blocks); +await editor.blocksToFullHTML(blocks); +// Node runs React's queued work before it exits. A crash there (#3230) gives a +// non-zero exit code, which fails the test. +console.log("exported"); diff --git a/tests/src/unit/nextjs/serverUtil.test.ts b/tests/src/unit/nextjs/serverUtil.test.ts index 56e9001d10..1e29f1545e 100644 --- a/tests/src/unit/nextjs/serverUtil.test.ts +++ b/tests/src/unit/nextjs/serverUtil.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; const TEST_APP_DIR = path.resolve(__dirname, "../../../nextjs-test-app"); let PORT: number; let BASE_URL: string; -const MODE = (process.env.NEXTJS_TEST_MODE || "dev") as "dev" | "build"; +const MODE = process.env.NEXTJS_TEST_MODE as "dev" | "build" | undefined; let nextProcess: ChildProcess; let serverOutput = ""; @@ -16,13 +16,12 @@ let serverErrors = ""; * Regression test for #942: @blocknote/server-util must work in Next.js * App Router server contexts (API routes) with serverExternalPackages. * - * Set NEXTJS_TEST_MODE=build to test against a production build (slower - * but catches different issues). Defaults to dev mode for fast iteration. + * Runs only when NEXTJS_TEST_MODE is set, as it packs the packages and + * installs them with npm: `dev`, or `build` for a production build (slower + * but catches different issues). */ -// TODO: Re-enable once @y/prosemirror v14 compatibility issues are resolved. -// Currently fails because @y/y no longer exports `Text` (needed by @y/prosemirror's -// sync-plugin) and stale tarball builds cause missing chunk errors. -describe.skip(`server-util in Next.js App Router (#942) [${MODE}]`, () => { +const describeIf = MODE ? describe : describe.skip; +describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { beforeAll(async () => { PORT = await getPort({ portRange: [3900, 4100] }); BASE_URL = `http://localhost:${PORT}`; From 37596e9ca8224f7566ae9f0ce359a336822afdbe Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 30 Oct 2026 08:43:10 +0100 Subject: [PATCH 2/3] test: harden built-package consumer smoke tests --- .../app/api/server-util/route.tsx | 79 +------ tests/nextjs-test-app/package.json | 20 +- tests/nextjs-test-app/setup.sh | 43 ++-- tests/src/unit/builtPackages/serverExport.mjs | 19 +- tests/src/unit/nextjs/serverUtil.test.ts | 205 ++++++++++++------ 5 files changed, 200 insertions(+), 166 deletions(-) diff --git a/tests/nextjs-test-app/app/api/server-util/route.tsx b/tests/nextjs-test-app/app/api/server-util/route.tsx index c1fe092494..65ef71c27e 100644 --- a/tests/nextjs-test-app/app/api/server-util/route.tsx +++ b/tests/nextjs-test-app/app/api/server-util/route.tsx @@ -1,50 +1,11 @@ // Mirrors ReactServer.test.tsx — see packages/server-util/src/context/react/ReactServer.test.tsx import { ServerBlockNoteEditor } from "@blocknote/server-util"; import { getDocument } from "@blocknote/server-util/dom"; -// import { -// BlockNoteSchema, -// defaultBlockSpecs, -// defaultProps, -// } from "@blocknote/core"; -// import { createReactBlockSpec } from "@blocknote/react"; -// import { createContext, useContext } from "react"; import { schema } from "../../shared-schema"; // Keep one DOM across requests and development hot reloads. const document = globalThis.document ?? getDocument(); -// Context block test from ReactServer.test.tsx — commented out because React's -// server bundle forbids createContext at runtime, even with dynamic require(). -// -// const TestContext = createContext(undefined); -// -// const ReactContextParagraphComponent = (props: any) => { -// const testData = useContext(TestContext); -// if (testData === undefined) { -// throw Error(); -// } -// return
; -// }; -// -// const ReactContextParagraph = createReactBlockSpec( -// { -// type: "reactContextParagraph" as const, -// propSchema: defaultProps, -// content: "inline" as const, -// }, -// { -// render: ReactContextParagraphComponent, -// }, -// ); -// -// const schemaWithContext = BlockNoteSchema.create({ -// blockSpecs: { -// ...defaultBlockSpecs, -// simpleReactCustomParagraph: schema.blockSpecs.simpleReactCustomParagraph, -// reactContextParagraph: ReactContextParagraph(), -// }, -// }); - export async function GET() { const results: Record = {}; @@ -68,36 +29,6 @@ export async function GET() { results["simpleReactBlock"] = `FAIL: ${e.message}`; } - // Mirrors ReactServer.test.tsx: "works for blocks with context" - // SKIPPED — React's server bundle forbids createContext at runtime. - results["reactContextBlock"] = - `PASS: skipped (createContext not available in React server bundle)`; - // - // try { - // const editor = ServerBlockNoteEditor.create({ schema: schemaWithContext }); - // const html = await editor.withReactContext( - // ({ children }) => ( - // {children} - // ), - // async () => - // editor.blocksToFullHTML([ - // { - // id: "1", - // type: "reactContextParagraph", - // content: "React Context Paragraph", - // }, - // ] as any), - // ); - // if (!html.includes("data-content-type")) { - // throw new Error( - // `Expected html to contain rendered block, got: ${html}`, - // ); - // } - // results["reactContextBlock"] = `PASS: ${html.substring(0, 200)}`; - // } catch (e: any) { - // results["reactContextBlock"] = `FAIL: ${e.message}`; - // } - // blocksToHTMLLossy with default blocks try { const editor = ServerBlockNoteEditor.create({ schema }, document); @@ -137,7 +68,15 @@ export async function GET() { const allPassed = Object.values(results).every((v) => v.startsWith("PASS")); return Response.json( - { allPassed, results }, + { + allPassed, + results, + // This is not an executed or passing scenario. React's server bundle + // forbids createContext; context coverage lives in ReactServer.test.tsx. + skipped: { + reactContextBlock: "createContext not available in React server bundle", + }, + }, { status: allPassed ? 200 : 500 }, ); } diff --git a/tests/nextjs-test-app/package.json b/tests/nextjs-test-app/package.json index d6304b6055..a9b5823754 100644 --- a/tests/nextjs-test-app/package.json +++ b/tests/nextjs-test-app/package.json @@ -7,12 +7,18 @@ "@blocknote/mantine": "file:.tarballs/blocknote-mantine.tgz", "@blocknote/react": "file:.tarballs/blocknote-react.tgz", "@blocknote/server-util": "file:.tarballs/blocknote-server-util.tgz", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "next": "^16.0.0", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "y-prosemirror": "^1.3.7", - "yjs": "^13.6.33" + "@mantine/core": "9.7.4", + "@mantine/hooks": "9.7.4", + "next": "16.4.3", + "react": "19.3.1", + "react-dom": "19.3.1", + "y-prosemirror": "1.3.7", + "yjs": "13.6.33" + }, + "devDependencies": { + "@types/node": "25.9.5", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "7.0.2" } } diff --git a/tests/nextjs-test-app/setup.sh b/tests/nextjs-test-app/setup.sh index bde3407890..c3e55d8c5d 100755 --- a/tests/nextjs-test-app/setup.sh +++ b/tests/nextjs-test-app/setup.sh @@ -1,27 +1,25 @@ #!/bin/bash -# Packs @blocknote packages as tarballs and installs them so Next.js sees -# real files in node_modules (not symlinks) — required for serverExternalPackages. -# -# Skips pack+install if packages haven't changed since last run. -set -e +# Packs and installs into a fresh consumer directory outside the checkout. +# Usage: bash setup.sh /absolute/path/to/empty-consumer-directory +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TARBALLS_DIR="$SCRIPT_DIR/.tarballs" # Resolve the repo root (works from git worktrees too). REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" PACKAGES_DIR="$REPO_ROOT/packages" - -# Compute a hash of package dist directories + package.json manifests. -HASH_FILE="$TARBALLS_DIR/.packages-hash" -CURRENT_HASH=$( (find "$PACKAGES_DIR/core/dist" "$PACKAGES_DIR/react/dist" "$PACKAGES_DIR/server-util/dist" "$PACKAGES_DIR/mantine/dist" -type f 2>/dev/null | sort | xargs cat 2>/dev/null; cat "$PACKAGES_DIR/core/package.json" "$PACKAGES_DIR/react/package.json" "$PACKAGES_DIR/server-util/package.json" "$PACKAGES_DIR/mantine/package.json" 2>/dev/null) | shasum -a 256 | cut -d' ' -f1) - -if [ -f "$HASH_FILE" ] && [ -d "$SCRIPT_DIR/node_modules/@blocknote/core" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ]; then - echo "Tarballs up to date, skipping pack+install" - exit 0 +CONSUMER_DIR="$(cd "${1:?Pass an empty consumer directory outside the checkout}" && pwd -P)" +case "$CONSUMER_DIR/" in + "$REPO_ROOT/"*) echo "Consumer must be outside the checkout" >&2; exit 1 ;; +esac +if [ -n "$(ls -A "$CONSUMER_DIR")" ]; then + echo "Consumer directory must be empty" >&2 + exit 1 fi -rm -rf "$TARBALLS_DIR" -mkdir -p "$TARBALLS_DIR" +# No setup cache: every run uses the current sources, manifests and tarballs. +cp -R "$SCRIPT_DIR/app" "$SCRIPT_DIR/next.config.ts" "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/package.json" "$CONSUMER_DIR/" +TARBALLS_DIR="$CONSUMER_DIR/.tarballs" +mkdir "$TARBALLS_DIR" # Pack each package (use pnpm pack so workspace:* refs are resolved to versions) for pkg in core react server-util mantine; do @@ -35,10 +33,9 @@ for pkg in core react server-util mantine; do mv blocknote-$pkg-*.tgz blocknote-$pkg.tgz done -cd "$SCRIPT_DIR" -# Install with npm (not pnpm — avoid workspace resolution) -rm -rf node_modules .next package-lock.json -npm install --ignore-scripts - -# Save hash for next run -echo "$CURRENT_HASH" > "$HASH_FILE" +cd "$CONSUMER_DIR" +# Install independently of the workspace, with real package directories for +# Next.js serverExternalPackages rather than pnpm's default symlink layout. +# Direct fixture dependencies are pinned. Transitives still resolve fresh: +# tarball integrity changes with every package build, so no frozen lockfile. +pnpm --ignore-workspace install --node-linker=hoisted --lockfile=false --ignore-scripts diff --git a/tests/src/unit/builtPackages/serverExport.mjs b/tests/src/unit/builtPackages/serverExport.mjs index d7fade422a..937ee8d00e 100644 --- a/tests/src/unit/builtPackages/serverExport.mjs +++ b/tests/src/unit/builtPackages/serverExport.mjs @@ -1,6 +1,7 @@ // Exports a document with React and optional blocks through // `ServerBlockNoteEditor`, in plain Node from the build output, as a server // app does. +import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { join, resolve } from "node:path"; @@ -67,9 +68,21 @@ const blocks = [ ]; const editor = ServerBlockNoteEditor.create({ schema }, getDocument()); -await editor.blocksToMarkdownLossy(blocks); -await editor.blocksToHTMLLossy(blocks); -await editor.blocksToFullHTML(blocks); +const markdown = await editor.blocksToMarkdownLossy(blocks); +const lossyHTML = await editor.blocksToHTMLLossy(blocks); +const fullHTML = await editor.blocksToFullHTML(blocks); +// Deliberately small content checks, not rendering snapshots. Completion alone +// would also accept empty exports or optional-block error placeholders. +for (const output of [markdown, lossyHTML, fullHTML]) { + for (const text of ["React block", "Left", "Right", "graph TD"]) { + assert.ok(output.includes(text), `Export is missing ${text}`); + } +} +for (const html of [lossyHTML, fullHTML]) { + assert.ok(html.includes(" { PORT = await getPort({ portRange: [3900, 4100] }); BASE_URL = `http://localhost:${PORT}`; - // Pack and install @blocknote packages as tarballs - execSync("bash setup.sh", { - cwd: TEST_APP_DIR, + // Prefer the harness-approved temp directory when available. A fresh + // directory outside the checkout prevents ancestor dependency fallbacks. + const harnessTempDir = path.join(tmpdir(), "opencode"); + testAppDir = mkdtempSync( + path.join( + existsSync(harnessTempDir) ? harnessTempDir : tmpdir(), + "blocknote-nextjs-", + ), + ); + execFileSync("bash", [path.join(FIXTURE_DIR, "setup.sh"), testAppDir], { + cwd: FIXTURE_DIR, stdio: "pipe", timeout: 240_000, }); if (MODE === "build") { // Build the Next.js app first - execSync("npx next build", { - cwd: TEST_APP_DIR, + execFileSync("pnpm", [...NEXT_ARGS, "build"], { + cwd: testAppDir, stdio: "pipe", timeout: 120_000, + env: { ...process.env, NODE_ENV: "production" }, }); + } - // Start production server - nextProcess = spawn("npx", ["next", "start", "--port", String(PORT)], { - cwd: TEST_APP_DIR, + const child = spawn( + "pnpm", + [ + ...NEXT_ARGS, + MODE === "build" ? "start" : "dev", + ...(MODE === "build" ? [] : ["--turbopack"]), + "--port", + String(PORT), + ], + { + cwd: testAppDir, stdio: ["ignore", "pipe", "pipe"], - detached: true, - }); - } else { - // Start dev server with Turbopack - nextProcess = spawn( - "npx", - ["next", "dev", "--turbopack", "--port", String(PORT)], - { - cwd: TEST_APP_DIR, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, NODE_ENV: "development" }, - detached: true, + env: { + ...process.env, + NODE_ENV: MODE === "build" ? "production" : "development", }, - ); - } + detached: true, + }, + ); + nextProcess = child; // Wait for "Ready" message await new Promise((resolve, reject) => { @@ -69,7 +105,7 @@ describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { let stderr = ""; - nextProcess.stdout?.on("data", (data: Buffer) => { + child.stdout?.on("data", (data: Buffer) => { const text = data.toString(); serverOutput += text; if (text.includes("Ready")) { @@ -78,55 +114,97 @@ describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { } }); - nextProcess.stderr?.on("data", (data: Buffer) => { + child.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); serverErrors += data.toString(); }); - nextProcess.on("error", (err) => { + child.on("error", (err) => { clearTimeout(timeout); reject(err); }); - nextProcess.on("exit", (code) => { - if (code !== null && code !== 0) { - clearTimeout(timeout); - reject(new Error(`Next.js exited with code ${code}: ${stderr}`)); - } + child.on("exit", (code) => { + clearTimeout(timeout); + reject( + new Error( + `Next.js exited before readiness with code ${code}: ${stderr}`, + ), + ); }); }); - }, 180_000); + }, 450_000); // setup (240s) + build (120s) + readiness (60s), with headroom afterAll(async () => { - if (nextProcess?.pid == null) { - return; - } - - await new Promise((resolve) => { - nextProcess.once("exit", () => resolve()); - - try { - // Kill the entire process group so Next.js children don't linger - process.kill(-nextProcess.pid!, "SIGTERM"); - } catch { - // Process may have already exited - resolve(); - return; + try { + const child = nextProcess; + if (child?.pid != null) { + const pid = child.pid; + await new Promise((resolve, reject) => { + // Start the timer before signaling, and cancel it when the child exits. + const timer = setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ) + ) { + reject(error); + return; + } + } + resolve(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + try { + process.kill(-pid, "SIGTERM"); + } catch (error) { + clearTimeout(timer); + if ( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ) { + resolve(); + } else { + reject(error); + } + } + }); + } + } finally { + if (testAppDir) { + rmSync(testAppDir, { recursive: true, force: true }); } + } + }, 15_000); - // Escalate to SIGKILL if still alive after 5s - setTimeout(() => { - try { - process.kill(-nextProcess.pid!, "SIGKILL"); - } catch { - // already gone - } - }, 5_000); - }); + it("cannot resolve workspace-only packages from the installed consumer", () => { + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import assert from "node:assert/strict"; + import { createRequire } from "node:module"; + const require = createRequire(process.cwd() + "/node_modules/@blocknote/server-util/package.json"); + assert.throws(() => require.resolve("@blocknote/math-block"), { code: "MODULE_NOT_FOUND" }); + `, + ], + { cwd: testAppDir, stdio: "pipe" }, + ); }); it("ServerBlockNoteEditor works in API route (mirrors ReactServer.test.tsx)", async () => { - const res = await fetch(`${BASE_URL}/api/server-util`); + const res = await fetchFromServer("/api/server-util"); const text = await res.text(); let body: any; try { @@ -155,13 +233,14 @@ describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { // Verify individual results match ReactServer.test.tsx scenarios expect(body.results.simpleReactBlock).toMatch(/^PASS:/); - expect(body.results.reactContextBlock).toMatch(/^PASS:/); + expect(body.results).not.toHaveProperty("reactContextBlock"); + expect(body.skipped.reactContextBlock).toContain("createContext"); expect(body.results.blocksToHTMLLossy).toMatch(/^PASS:/); expect(body.results.yDocRoundtrip).toMatch(/^PASS:/); }, 30_000); - it("Editor page with shared schema renders without errors", async () => { - const res = await fetch(`${BASE_URL}/editor`); + it("serves the editor page shell (not browser hydration)", async () => { + const res = await fetchFromServer("/editor"); const html = await res.text(); if (res.status !== 200) { From 2b91408fa70e0c3bd9a547a00d99b3ccd0028ff7 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 30 Oct 2026 09:24:04 +0100 Subject: [PATCH 3/3] test: simplify consumer harness and strengthen math assertions --- .../app/api/server-util/route.tsx | 12 +- tests/src/unit/builtPackages/serverExport.mjs | 14 ++- tests/src/unit/nextjs/serverUtil.test.ts | 107 ++++-------------- tests/vitestSetup.ts | 4 +- 4 files changed, 43 insertions(+), 94 deletions(-) diff --git a/tests/nextjs-test-app/app/api/server-util/route.tsx b/tests/nextjs-test-app/app/api/server-util/route.tsx index 65ef71c27e..1f93d9051b 100644 --- a/tests/nextjs-test-app/app/api/server-util/route.tsx +++ b/tests/nextjs-test-app/app/api/server-util/route.tsx @@ -67,16 +67,10 @@ export async function GET() { const allPassed = Object.values(results).every((v) => v.startsWith("PASS")); + // React's server bundle forbids createContext; that coverage remains in + // ReactServer.test.tsx rather than being reported as a passing scenario here. return Response.json( - { - allPassed, - results, - // This is not an executed or passing scenario. React's server bundle - // forbids createContext; context coverage lives in ReactServer.test.tsx. - skipped: { - reactContextBlock: "createContext not available in React server bundle", - }, - }, + { allPassed, results }, { status: allPassed ? 200 : 500 }, ); } diff --git a/tests/src/unit/builtPackages/serverExport.mjs b/tests/src/unit/builtPackages/serverExport.mjs index 937ee8d00e..144989be7e 100644 --- a/tests/src/unit/builtPackages/serverExport.mjs +++ b/tests/src/unit/builtPackages/serverExport.mjs @@ -78,9 +78,21 @@ for (const output of [markdown, lossyHTML, fullHTML]) { assert.ok(output.includes(text), `Export is missing ${text}`); } } +assert.ok(markdown.includes("$x^2$"), "Inline math is missing from Markdown"); +assert.ok( + markdown.includes("$$\na^2 + b^2 = c^2\n$$"), + "Display math is missing from Markdown", +); for (const html of [lossyHTML, fullHTML]) { assert.ok(html.includes("${formula}`, + ), + `MathML annotation is missing ${formula}`, + ); + } assert.ok(html.includes("A --> B"), "Diagram source is missing"); } // Node runs React's queued work before it exits. A crash there (#3230) gives a diff --git a/tests/src/unit/nextjs/serverUtil.test.ts b/tests/src/unit/nextjs/serverUtil.test.ts index 7d965b32cb..37d3696b51 100644 --- a/tests/src/unit/nextjs/serverUtil.test.ts +++ b/tests/src/unit/nextjs/serverUtil.test.ts @@ -137,47 +137,33 @@ describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { afterAll(async () => { try { - const child = nextProcess; - if (child?.pid != null) { - const pid = child.pid; - await new Promise((resolve, reject) => { - // Start the timer before signaling, and cancel it when the child exits. - const timer = setTimeout(() => { - try { - process.kill(-pid, "SIGKILL"); - } catch (error) { - if ( - !( - error instanceof Error && - "code" in error && - error.code === "ESRCH" - ) - ) { - reject(error); - return; - } - } - resolve(); - }, 5_000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); + if (nextProcess?.pid != null) { + // This is a disposable server. Kill the entire detached group, then + // wait for its children too, not just the pnpm wrapper, before deleting it. + const deadline = Date.now() + 5_000; + for (let signal: NodeJS.Signals | 0 = "SIGKILL"; ; signal = 0) { try { - process.kill(-pid, "SIGTERM"); + process.kill(-nextProcess.pid, signal); } catch (error) { - clearTimeout(timer); - if ( - error instanceof Error && - "code" in error && - error.code === "ESRCH" - ) { - resolve(); + if (error instanceof Error && "code" in error) { + if (error.code === "ESRCH") { + break; + } + // A denied existence probe does not establish disappearance. + if (signal !== 0 || error.code !== "EPERM") { + throw error; + } } else { - reject(error); + throw error; } } - }); + if (Date.now() >= deadline) { + throw new Error( + `Next.js process group ${nextProcess.pid} did not exit`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } } } finally { if (testAppDir) { @@ -206,25 +192,8 @@ describeIf(`server-util in Next.js App Router (#942) [${MODE}]`, () => { it("ServerBlockNoteEditor works in API route (mirrors ReactServer.test.tsx)", async () => { const res = await fetchFromServer("/api/server-util"); const text = await res.text(); - let body: any; - try { - body = JSON.parse(text); - } catch { - const nextDataMatch = text.match( - /