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/app/api/server-util/route.tsx b/tests/nextjs-test-app/app/api/server-util/route.tsx index 724b3fe686..1f93d9051b 100644 --- a/tests/nextjs-test-app/app/api/server-util/route.tsx +++ b/tests/nextjs-test-app/app/api/server-util/route.tsx @@ -1,52 +1,17 @@ // Mirrors ReactServer.test.tsx — see packages/server-util/src/context/react/ReactServer.test.tsx import { ServerBlockNoteEditor } from "@blocknote/server-util"; -// import { -// BlockNoteSchema, -// defaultBlockSpecs, -// defaultProps, -// } from "@blocknote/core"; -// import { createReactBlockSpec } from "@blocknote/react"; -// import { createContext, useContext } from "react"; +import { getDocument } from "@blocknote/server-util/dom"; import { schema } from "../../shared-schema"; -// 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(), -// }, -// }); +// Keep one DOM across requests and development hot reloads. +const document = globalThis.document ?? getDocument(); export async function GET() { const results: Record = {}; // Mirrors ReactServer.test.tsx: "works for simple blocks" try { - const editor = ServerBlockNoteEditor.create({ schema }); + const editor = ServerBlockNoteEditor.create({ schema }, document); const html = await editor.blocksToFullHTML([ { id: "1", @@ -64,39 +29,9 @@ 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 }); + const editor = ServerBlockNoteEditor.create({ schema }, document); const html = await editor.blocksToHTMLLossy([ { type: "paragraph", @@ -113,7 +48,7 @@ export async function GET() { // Yjs roundtrip try { - const editor = ServerBlockNoteEditor.create({ schema }); + const editor = ServerBlockNoteEditor.create({ schema }, document); const blocks = [ { type: "paragraph", @@ -132,6 +67,8 @@ 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 }, { status: allPassed ? 200 : 500 }, diff --git a/tests/nextjs-test-app/package.json b/tests/nextjs-test-app/package.json index 65edcec930..a9b5823754 100644 --- a/tests/nextjs-test-app/package.json +++ b/tests/nextjs-test-app/package.json @@ -3,14 +3,22 @@ "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", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "next": "^16.0.0", - "react": "^19.2.3", - "react-dom": "^19.2.3" + "@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.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 56efd25f95..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 @@ -29,36 +27,15 @@ 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) - -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 +for pkg in core react server-util mantine; do + mv blocknote-$pkg-*.tgz blocknote-$pkg.tgz +done -# 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/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..144989be7e --- /dev/null +++ b/tests/src/unit/builtPackages/serverExport.mjs @@ -0,0 +1,100 @@ +// 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"; +import { pathToFileURL } from "node:url"; + +const packagesDir = resolve(import.meta.dirname, "../../../../packages"); + +function entry(dir, subpath = ".") { + const pkg = JSON.parse( + readFileSync(join(packagesDir, dir, "package.json"), "utf8"), + ); + return pathToFileURL(resolve(packagesDir, dir, pkg.exports[subpath].import)) + .href; +} + +const { BlockNoteSchema } = await import(entry("core")); +const { createReactBlockSpec } = await import(entry("react")); +const { ServerBlockNoteEditor } = await import(entry("server-util")); +const { getDocument } = await import(entry("server-util", "./dom")); +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 }, getDocument()); +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}`); + } +} +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 +// 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..37d3696b51 100644 --- a/tests/src/unit/nextjs/serverUtil.test.ts +++ b/tests/src/unit/nextjs/serverUtil.test.ts @@ -1,66 +1,101 @@ -import { execSync, spawn, ChildProcess } from "child_process"; +import { execFileSync, spawn, ChildProcess } from "child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { getPort } from "get-port-please"; import path from "path"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; -const TEST_APP_DIR = path.resolve(__dirname, "../../../nextjs-test-app"); +const FIXTURE_DIR = path.resolve(__dirname, "../../../nextjs-test-app"); +// pnpm 11 otherwise reinstalls before exec using its default isolated linker, +// undoing setup.sh's hoisted install and duplicating externalized packages. +const NEXT_ARGS = [ + "--ignore-workspace", + "--config.verify-deps-before-run=false", + "exec", + "next", +]; +let testAppDir: string | undefined; 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; +if (MODE !== undefined && MODE !== "dev" && MODE !== "build") { + throw new Error("NEXTJS_TEST_MODE must be dev or build"); +} -let nextProcess: ChildProcess; +let nextProcess: ChildProcess | undefined; let serverOutput = ""; let serverErrors = ""; +async function fetchFromServer(route: string) { + try { + return await fetch(`${BASE_URL}${route}`); + } catch (error) { + throw new Error( + `Next.js request failed\n${serverOutput}\n${serverErrors}`, + { cause: error }, + ); + } +} + /** * 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 outside the workspace: `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}`; - // 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) => { @@ -70,7 +105,7 @@ describe.skip(`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")) { @@ -79,75 +114,86 @@ describe.skip(`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; - } - - // Escalate to SIGKILL if still alive after 5s - setTimeout(() => { - try { - process.kill(-nextProcess.pid!, "SIGKILL"); - } catch { - // already gone + try { + 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(-nextProcess.pid, signal); + } catch (error) { + 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 { + 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)); } - }, 5_000); - }); + } + } finally { + if (testAppDir) { + rmSync(testAppDir, { recursive: true, force: true }); + } + } + }, 15_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 { - body = JSON.parse(text); - } catch { - const nextDataMatch = text.match( - /