diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml index b08c292cc..32eae11b2 100644 --- a/.github/workflows/build-core.yml +++ b/.github/workflows/build-core.yml @@ -6,6 +6,7 @@ on: branches: [main] env: + NODE_VERSION: 22 ZIG_VERSION: 0.15.2 # Workaround for bug in Zig 0.15.2 (fixed in next version) # https://github.com/ziglang/zig/issues/25805 @@ -69,6 +70,11 @@ jobs: with: bun-version: latest + - name: Setup Node.js + uses: actions/setup-node@v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: @@ -89,20 +95,30 @@ jobs: ls -la packages/core/node_modules/@opentui/ ls -la packages/core/node_modules/@opentui/core-${{ matrix.platform }}/ + - name: Build TypeScript library + run: | + cd packages/core + bun run build:lib + - name: Run native tests run: | - cd packages/core/src/zig - zig build test --summary all + cd packages/core + bun run test:native - - name: Build TypeScript library + - name: Run Bun tests run: | cd packages/core - bun run build:lib + bun run test:bun + + - name: Run Node.js tests + run: | + cd packages/core + bun run test:nodejs - - name: Run TypeScript tests + - name: Run dist tests run: | cd packages/core - bun run test:js + bun run test:dist # Gate job for branch protection build-complete: diff --git a/.github/workflows/build-react.yml b/.github/workflows/build-react.yml index 74ef5801b..d9b305d9b 100644 --- a/.github/workflows/build-react.yml +++ b/.github/workflows/build-react.yml @@ -6,6 +6,7 @@ on: branches: [main] env: + NODE_VERSION: 22 # Workaround for bug in Zig 0.15.2 (fixed in next version) # https://github.com/ziglang/zig/issues/25805 ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-global-cache @@ -29,6 +30,11 @@ jobs: with: bun-version: latest + - name: Setup Node.js + uses: actions/setup-node@v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: @@ -47,10 +53,20 @@ jobs: cd packages/react bun run build --ci - - name: Run tests + - name: Run Bun tests + run: | + cd packages/react + bun run test:bun + + - name: Run Node.js tests + run: | + cd packages/react + bun run test:nodejs + + - name: Run dist tests run: | cd packages/react - bun run test + bun run test:dist # Gate job for branch protection build-complete: diff --git a/.github/workflows/build-solid.yml b/.github/workflows/build-solid.yml index c94ce6007..279282282 100644 --- a/.github/workflows/build-solid.yml +++ b/.github/workflows/build-solid.yml @@ -6,6 +6,7 @@ on: branches: [main] env: + NODE_VERSION: 22 # Workaround for bug in Zig 0.15.2 (fixed in next version) # https://github.com/ziglang/zig/issues/25805 ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-global-cache @@ -29,6 +30,11 @@ jobs: with: bun-version: latest + - name: Setup Node.js + uses: actions/setup-node@v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Setup Zig uses: goto-bus-stop/setup-zig@v2 with: @@ -47,10 +53,20 @@ jobs: cd packages/solid bun run build --ci - - name: Run tests + - name: Run Bun tests + run: | + cd packages/solid + bun run test:bun + + - name: Run Node.js tests + run: | + cd packages/solid + bun run test:nodejs + + - name: Run dist tests run: | cd packages/solid - bun run test + bun run test:dist # Gate job for branch protection build-complete: diff --git a/NODEJS_COMPAT.md b/NODEJS_COMPAT.md new file mode 100644 index 000000000..5e3fa68d0 --- /dev/null +++ b/NODEJS_COMPAT.md @@ -0,0 +1,509 @@ +# Node.js Compatibility Plan + +## Summary + +Goal: make the main OpenTUI packages usable from Node.js with no user-facing preload flags such as `-r` or `--import`, and without introducing import maps. + +The core approach is: + +1. Replace Bun-specific runtime imports in portable code with project-owned compat modules. +2. Keep Bun-only features isolated behind explicit Bun-only entrypoints. +3. Publish Node-consumable build artifacts instead of relying on loader hooks at runtime. +4. Add a real Node test lane for every package that is meant to work in Node. + +This plan intentionally moves compatibility out of process bootstrapping and into normal module code. + +## Principles + +- No import maps. +- No required `-r` / `--import` flags for consumers. +- No production dependence on `packages/core/src/nodejs/compat.ts`. +- Prefer standard platform APIs over Bun shims where possible. +- Use a stable compat import surface inside the repo. +- Keep the Bun-only API surface explicit instead of partially emulated. + +## Target End State + +Node users can install and import these entrypoints directly: + +- `@opentui/core` +- `@opentui/core/testing` +- `@opentui/react` +- `@opentui/react/test-utils` +- `@opentui/solid` + +These entrypoints remain Bun-only in the first pass: + +- `@opentui/core/runtime-plugin` +- `@opentui/core/runtime-plugin-support` +- `@opentui/core/3d` +- `@opentui/react/runtime-plugin-support` +- `@opentui/solid/runtime-plugin-support` +- `@opentui/solid/preload` +- `@opentui/solid/bun-plugin` + +If a Node user imports a Bun-only entrypoint, they should get a clean, deterministic error from a stub module, not a random Bun symbol failure. + +## Current Problems + +- Portable runtime code still imports `bun:ffi`. +- Portable runtime code still calls `Bun.*`. +- Tree-sitter assets use Bun import attributes such as `with { type: "file" }`. +- The current Node path relies on `packages/core/src/nodejs/compat.ts`, which must be preloaded before any Bun-specific import is evaluated. +- Published `dist` artifacts are still Bun-oriented, so a consumer import is not the same thing as a Vitest import. + +## Main Design + +### 1. Introduce a compat surface in `packages/core/src/compat` + +Planned modules: + +- `packages/core/src/compat/ffi.ts` +- `packages/core/src/compat/Worker.ts` +- `packages/core/src/compat/runtime.ts` +- `packages/core/src/compat/resolvers.ts` +- `packages/core/src/compat/test.ts` + +Portable code will only import from these modules instead of `bun:ffi`, `bun:test`, `Bun.*`, or Bun import attributes. + +### 2. Stable facade, flexible implementation + +The import surface should be stable even if implementation differs by runtime. + +That means call sites should always import: + +```ts +import { dlopen, ptr, toArrayBuffer } from "./compat/ffi.js" +import { Worker } from "./compat/Worker.js" +import { resolveFile, readTextFile } from "./compat/resolvers.js" +import { sleep, stringWidth, stripANSI, writeFile } from "./compat/runtime.js" +``` + +The implementation behind those modules can be either: + +- a single portable file when that is straightforward, or +- a thin facade with runtime-specific internals when a single file would force Bun-only syntax back into the source. + +Important constraint: the stable import surface is required, but the implementation does not need to be a single literal file if that becomes awkward. + +## Compat Module Plan + +### `compat/ffi.ts` + +Purpose: + +- Replace every `bun:ffi` import in portable runtime code. +- Ensure generated `.d.ts` files stop referencing `bun:ffi`. + +Exports should cover the exact subset the project uses today: + +- `dlopen` +- `JSCallback` +- `ptr` +- `toArrayBuffer` +- `Pointer` +- `FFIType` +- any other Bun FFI types currently exposed in public types + +Implementation plan: + +- Reuse the logic already developed in `packages/core/src/nodejs/bunModules/ffi.ts` for Node. +- In Bun, delegate to Bun FFI. +- Own the exported types under the compat module so declaration output references `./compat/ffi` instead of `bun:ffi`. + +Files to migrate first: + +- `packages/core/src/buffer.ts` +- `packages/core/src/edit-buffer.ts` +- `packages/core/src/editor-view.ts` +- `packages/core/src/NativeSpanFeed.ts` +- `packages/core/src/renderer.ts` +- `packages/core/src/syntax-style.ts` +- `packages/core/src/text-buffer.ts` +- `packages/core/src/text-buffer-view.ts` +- `packages/core/src/zig-structs.ts` +- `packages/core/src/zig.ts` +- `packages/core/src/lib/clipboard.ts` +- `packages/core/src/3d/canvas.ts` + +### `compat/Worker.ts` + +Purpose: + +- Replace implicit reliance on global `Worker`. +- Make worker creation explicit and runtime-neutral. + +Required surface: + +- `new Worker(string | URL)` +- `onmessage` +- `onerror` +- `postMessage` +- `terminate` + +Implementation plan: + +- Bun path: use the native worker implementation. +- Node path: wrap `node:worker_threads` and preserve the web-worker style API already used by `TreeSitterClient`. + +First consumer: + +- `packages/core/src/lib/tree-sitter/client.ts` + +### `compat/runtime.ts` + +Purpose: + +- Remove remaining `Bun.*` calls from portable runtime code. + +Initial surface: + +- `sleep(ms)` +- `stringWidth(text)` +- `stripANSI(text)` +- `writeFile(path, data, options?)` +- possibly `readTextFile(path)` if useful outside resolvers + +Rules: + +- Prefer direct standard-library replacements when possible. +- Keep the compat API narrow and project-owned. +- Do not preserve a fake global `Bun` object in the long-term design. + +Expected migrations: + +- `packages/core/src/lib/paste.ts` +- `packages/core/src/lib/extmarks.ts` +- `packages/core/src/renderables/LineNumberRenderable.ts` +- `packages/core/src/renderables/ScrollBar.ts` +- `packages/core/src/renderer.ts` +- `packages/core/src/zig.ts` + +### `compat/resolvers.ts` + +Purpose: + +- Replace Bun import attributes in portable code. + +Planned API: + +```ts +const javascriptHighlights = resolveFile(import.meta.url, "./assets/javascript/highlights.scm") +const shaderTemplate = readTextFile(import.meta.url, "./shaders/supersampling.wgsl") +``` + +Suggested helpers: + +- `resolveFile(fromImportMetaUrl, relativePath): string` +- `readTextFile(fromImportMetaUrl, relativePath): string` +- optionally `resolveUrl(fromImportMetaUrl, relativePath): URL` + +Usage plan: + +- Tree-sitter asset paths should use `resolveFile(...)`. +- Shader source should use `readTextFile(...)`. +- Generated native package stubs should use direct `new URL(..., import.meta.url)` plus `fileURLToPath(...)`; they do not need Bun import attributes at all. + +Files to migrate: + +- `packages/core/src/lib/tree-sitter/default-parsers.ts` +- `packages/core/src/3d/canvas.ts` +- `packages/core/scripts/build.ts` for native package index generation +- `packages/core/src/lib/tree-sitter/assets/update.ts` so future generated files use the resolver helpers + +### `compat/test.ts` + +Purpose: + +- Provide one shared test import surface for Bun and Vitest. + +Scope: + +- Repo tests only. +- Not part of the supported runtime API. + +Exports: + +- `describe` +- `it` +- `test` +- `expect` +- `beforeEach` +- `afterEach` +- `beforeAll` +- `afterAll` +- `mock` +- `spyOn` +- shared matcher setup such as `toInclude` + +This should replace direct `bun:test` imports in packages that need a Node test lane. + +## Source Migration Plan + +### Phase 1: Core portable runtime + +1. Add `packages/core/src/compat/{ffi,Worker,runtime,resolvers}.ts`. +2. Replace all `bun:ffi` imports in portable code with `./compat/ffi.js`. +3. Replace global `Worker` usage with `./compat/Worker.js`. +4. Replace `with { type: "file" }` and `with { type: "text" }` with resolver helpers. +5. Replace remaining `Bun.*` calls in portable runtime code. +6. Regenerate any generated files that currently emit Bun-specific asset imports. +7. Ensure the main `@opentui/core` runtime path no longer depends on `packages/core/src/nodejs/compat.ts`. + +Deliverable: + +- A Node import of the portable `core` source no longer requires preload hooks to resolve Bun-specific runtime APIs. + +### Phase 2: Explicit Bun-only isolation + +Keep these entrypoints Bun-only in the first pass: + +- `packages/core/src/runtime-plugin.ts` +- `packages/core/src/runtime-plugin-support.ts` +- `packages/react/scripts/runtime-plugin-support.ts` +- `packages/solid/scripts/runtime-plugin-support.ts` +- `packages/solid/scripts/solid-plugin.ts` +- `packages/solid/scripts/preload.ts` +- `packages/core/src/3d.ts` and `packages/core/src/3d/**` unless a separate 3D Node plan is approved + +Tasks: + +- Move any Bun-only imports out of otherwise portable modules. +- Add explicit Node stubs for Bun-only published subpaths. +- Keep Bun tests for these entrypoints in the Bun lane only. + +Deliverable: + +- The portable API surface is cleanly separated from the Bun-only API surface. + +### Phase 3: Packaging and publish output + +Portable source is not enough; published `dist` must also be Node-safe. + +Tasks for `@opentui/core`: + +1. Stop publishing Bun-targeted output as the only artifact. +2. Build a Node-safe ESM artifact with no `bun:ffi`, no `Bun.*`, and no Bun import attributes. +3. Keep a Bun build only where it provides real value. +4. Use package `exports` conditions to select Node vs default artifacts where needed. +5. For native optional packages such as `@opentui/core-darwin-arm64`, generate a portable `index.js` that resolves the adjacent library path with `fileURLToPath(new URL(...))`. +6. Ensure generated declarations reference compat modules instead of `bun:ffi`. + +Tasks for `@opentui/react` and `@opentui/solid`: + +1. Rebuild against the portable `@opentui/core` output. +2. Publish Node-safe main entrypoints. +3. Publish explicit Node stubs for Bun-only subpaths. +4. Stop copying raw `.ts` Bun-only entrypoints into published `dist` when a stub or transpiled JS file is more appropriate. + +Export map policy: + +- Use package `exports`. +- Do not introduce import maps. +- Prefer `"node"` and `"default"` conditions when runtimes need different files. +- Use a single file for both runtimes when the artifact is genuinely portable. + +Deliverable: + +- `node -e 'import("@opentui/core")'` works against published output with no preload flags. + +### Phase 4: Test migration + +#### Shared test policy + +- Bun remains the primary lane for Bun-only features. +- Node gets a first-class lane for every package intended to work in Node. +- Portable tests should not import `bun:test` directly. +- Node snapshots should be separate from Bun snapshots. + +#### `@opentui/core` + +Add and maintain: + +- `test:js` for Bun source tests +- `test:nodejs` for Node source tests +- `test:nodejs:dist` for plain Node imports against built output + +Node source lane: + +- Use Vitest while the migration is in progress. +- Prefer direct source imports once portable compat modules exist. +- Keep current hook-based Node test setup only as a temporary bridge. + +Node dist lane: + +- Use plain `node` to import built entrypoints. +- No `--import`. +- No custom loader hooks. +- This lane is the release gate for Node compatibility. + +Tests that remain Bun-only: + +- runtime plugin tests requiring `import { plugin } from "bun"` +- any tests for Bun-only entrypoints +- any tests that intentionally validate Bun plugin behavior + +#### `@opentui/react` + +Tasks: + +1. Add `packages/react/vitest.config.ts`. +2. Replace `bun:test` imports with a shared test compat import. +3. Add Node snapshots with a `.nodejs.snap` suffix. +4. Add `test:nodejs`. +5. Add `test:nodejs:dist` smoke coverage for the published package. + +Expected Bun-only exclusions: + +- `packages/react/tests/runtime-plugin-support.test.ts` + +#### `@opentui/solid` + +Tasks: + +1. Add `packages/solid/vitest.config.ts`. +2. Replace `bun:test` imports with a shared test compat import. +3. Add Node snapshots with a `.nodejs.snap` suffix. +4. Add `test:nodejs`. +5. Add `test:nodejs:dist` smoke coverage for the published package. + +Important requirement: + +- The Solid Node test lane must use a transform equivalent to the current Solid Bun plugin behavior. +- Reuse the current Babel-based logic where possible so Node and Bun produce the same JSX transform semantics. + +Expected Bun-only exclusions: + +- `packages/solid/tests/runtime-plugin-support*.test.ts` +- `packages/solid/tests/solid-plugin.test.ts` +- any tests whose fixtures import `plugin` from `bun` + +#### Root scripts + +Planned root-level scripts: + +- `test:bun` +- `test:nodejs` +- `test:nodejs:dist` +- `test` + +Recommended policy: + +- `test` should run the Bun lane plus the Node source lane. +- CI release jobs should also run `test:nodejs:dist`. + +### Phase 5: Cleanup + +Once the portable runtime no longer depends on loader hooks: + +1. Remove or retire `packages/core/src/nodejs/compat.ts`. +2. Remove or retire `packages/core/src/nodejs/bunModules/test.ts`. +3. Remove any Vitest config that exists only to inject the Node preload hook. +4. Remove build or test comments that describe the old preload-based path as the supported solution. + +## Package-by-Package Scope + +### `packages/core` + +Required in first pass: + +- portable main entrypoint +- portable `testing` entrypoint +- portable native library path resolution +- Node-safe declarations +- Node source tests +- Node dist smoke tests + +Deferred: + +- `runtime-plugin` +- `runtime-plugin-support` +- `3d` + +### `packages/react` + +Required in first pass: + +- portable main entrypoint +- portable `test-utils` +- Node tests +- Node dist smoke tests + +Deferred: + +- `runtime-plugin-support` + +### `packages/solid` + +Required in first pass: + +- portable main entrypoint +- Node tests +- Node dist smoke tests + +Deferred: + +- `runtime-plugin-support` +- `bun-plugin` +- `preload` + +### `packages/web` + +No dedicated runtime compatibility work is required for the initial port beyond consuming the portable `core` package correctly. + +## Build Strategy + +Recommended build policy: + +1. Make the runtime code portable first. +2. Then simplify builds where portability makes a separate Bun build unnecessary. +3. Only keep separate Bun and Node output trees where the implementation truly differs. + +Suggested artifact layout if split output is still needed: + +- `dist/node/**` +- `dist/bun/**` + +Suggested artifact layout if most code becomes portable: + +- one shared portable output tree +- separate stubs only for Bun-only subpaths + +The exact output layout is less important than this invariant: + +- published Node entrypoints must work when imported by plain `node` + +## Acceptance Criteria + +The Node port is complete when all of the following are true: + +1. `@opentui/core`, `@opentui/core/testing`, `@opentui/react`, and `@opentui/solid` import cleanly in plain Node with no preload flags. +2. Portable source files no longer import `bun:ffi`, `bun:test`, or use Bun import attributes. +3. Portable runtime code no longer depends on a global `Bun` object. +4. Published declaration files for portable entrypoints no longer reference `bun:ffi`. +5. Bun-only subpaths fail cleanly in Node with an explicit message. +6. Each supported package has a working Node test lane. +7. Each supported package has a Node dist smoke test. + +## Implementation Order + +Recommended order: + +1. Add compat modules in `packages/core/src/compat`. +2. Migrate `@opentui/core` portable runtime code. +3. Regenerate tree-sitter asset resolver output. +4. Fix native package stubs. +5. Make `@opentui/core` dist Node-safe. +6. Add `@opentui/core` Node dist smoke tests. +7. Add `@opentui/react` Node test lane. +8. Add `@opentui/solid` Node test lane. +9. Make `react` and `solid` dist Node-safe. +10. Add root Node test scripts and CI lanes. +11. Remove the old preload-hook path. + +## Notes + +- This plan does not require import maps. +- This plan does not require users to change docs to mention `-r` or `--import`. +- This plan assumes the stable compat import surface lives in `packages/core/src/compat`. +- `packages/core/src/compat/test.ts` is useful for repo tests, but it should not be treated as part of the supported runtime API unless that becomes an explicit product decision. diff --git a/bun.lock b/bun.lock index 5241241c7..9757f4be5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,10 @@ "": { "name": "@opentui", "devDependencies": { + "commander": "^13.1.0", "oxfmt": "0.41.0", "oxlint": "1.56.0", + "vitest": "4.1.3", }, }, "packages/core": { @@ -17,13 +19,15 @@ "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", + "string-width": "8.2.0", + "strip-ansi": "7.2.0", "yoga-layout": "3.2.1", }, "devDependencies": { "@types/bun": "latest", "@types/node": "^24.0.0", + "@types/ref": "0.0.32", "@types/three": "0.177.0", - "commander": "^13.1.0", "typescript": "^5", "web-tree-sitter": "0.25.10", }, @@ -36,8 +40,10 @@ "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", + "koffi": "2.15.6", "planck": "^1.4.2", "three": "0.177.0", + "unsafe-pointer": "0.2.0", }, "peerDependencies": { "web-tree-sitter": "0.25.10", @@ -116,6 +122,9 @@ }, }, }, + "trustedDependencies": [ + "koffi", + ], "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -365,6 +374,18 @@ "@opentui/core": ["@opentui/core@workspace:packages/core"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZuPWAawlVat6ZHb8vaH/CVUeGwI0pI4vd+6zz1ZocZn95ZWJztfyhzNZOJrq1WjHmUROieJ7cOuYUZfvYNuLrg=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXxhz654vXgEu2wrFFFFnrSWbyk6/r6nXNnDTcMRWofdMZQLx87NhbcsErNmz9KmFdzoPiQSmlpYubLflKKzqQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-v3z0QWpRS3p8blE/A7pTu15hcFMtSndeiYhRxhrjp6zAhQ+UlruQs9DAG1ifSuVO1RJJ0pUKklFivdbu0pMzuw=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-o/m9mD1dvOCwkxOUUyoEILl+d6tzh/85foJc4uqjXYi71NNcwg8u+Eq3/gdHuSKnlT1pusCPKoS1IDuBvZE24A=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-Rwp7JOwrYm4wtzPHY2vv+2l91LXmKSI7CtbmWN1sSUGhBPtPGSvfwux3W5xaAZQa2KPEXicPjaKJZc+pob3YRg=="], + "@opentui/react": ["@opentui/react@workspace:packages/react"], "@opentui/solid": ["@opentui/solid@workspace:packages/solid"], @@ -517,6 +538,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -529,10 +552,14 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -553,6 +580,8 @@ "@types/react-reconciler": ["@types/react-reconciler@0.32.3", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA=="], + "@types/ref": ["@types/ref@0.0.32", "", { "dependencies": { "@types/node": "*" } }, "sha512-5q2nxslQF7GnoVzCYPsHD1B8pU020l6zy2rNw5Dzd01plmnRDj0b6thsXUOtbMjVEMAQ5uCgoajJP71f3FWiyw=="], + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], "@types/three": ["@types/three@0.177.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A=="], @@ -565,6 +594,20 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitest/expect": ["@vitest/expect@4.1.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.3", "", { "dependencies": { "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.3", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg=="], + + "@vitest/runner": ["@vitest/runner@4.1.3", "", { "dependencies": { "@vitest/utils": "4.1.3", "pathe": "^2.0.3" } }, "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.3", "", {}, "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw=="], + + "@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="], + "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -589,6 +632,8 @@ "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astro": ["astro@5.16.11", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", "@astrojs/markdown-remark": "6.3.10", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.20.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.1", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.3", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-Z7kvkTTT5n6Hn5lCm6T3WU6pkxx84Hn25dtQ6dR7ATrBGq9eVa8EuB/h1S8xvaoVyCMZnIESu99Z9RJfdLRLDA=="], @@ -627,7 +672,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -645,6 +690,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -729,7 +776,7 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -763,6 +810,8 @@ "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -789,7 +838,7 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -877,6 +926,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "koffi": ["koffi@2.15.6", "", {}, "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw=="], + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1019,6 +1070,8 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1027,6 +1080,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], @@ -1073,6 +1128,8 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], @@ -1183,6 +1240,8 @@ "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1197,15 +1256,19 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], @@ -1221,6 +1284,8 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -1229,6 +1294,8 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1275,6 +1342,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unsafe-pointer": ["unsafe-pointer@0.2.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" } }, "sha512-5AzhXe8ZGzV/wVVfomw7KszPt4RPOpVcsWi66DrMrzy6fi86+rTiTx3Gu88UshFhfQLAXbFvgRyHVpQ+nZ7bvg=="], + "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1291,12 +1360,16 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitest": ["vitest@4.1.3", "", { "dependencies": { "@vitest/expect": "4.1.3", "@vitest/mocker": "4.1.3", "@vitest/pretty-format": "4.1.3", "@vitest/runner": "4.1.3", "@vitest/snapshot": "4.1.3", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.3", "@vitest/browser-preview": "4.1.3", "@vitest/browser-webdriverio": "4.1.3", "@vitest/coverage-istanbul": "4.1.3", "@vitest/coverage-v8": "4.1.3", "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1331,11 +1404,7 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@opentui/react/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], - - "@opentui/solid/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], - - "@opentui/web/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@astrojs/mdx/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -1345,10 +1414,14 @@ "astro/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "astro/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "astro/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1375,18 +1448,28 @@ "unstorage/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], - "@opentui/react/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@opentui/solid/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@opentui/web/@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "boxen/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "boxen/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "widest-line/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..ed51a31f6 --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +bun = "latest" +zig = "{{ read_file(path='.zig-version') | trim }}" +node = "22.22.2" diff --git a/package.json b/package.json index 80f4841a8..ad1e7bd3a 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,17 @@ "publish:react": "cd packages/react && bun run publish", "publish:solid": "cd packages/solid && bun run publish", "prepare-release": "bun scripts/prepare-release.ts", - "test": "bun run --filter '@opentui/core' --filter '@opentui/solid' --filter '@opentui/react' --if-present test" + "test": "bun run --filter '@opentui/core' --filter '@opentui/solid' --filter '@opentui/react' --if-present test", + "test:nodejs": "bun run --filter '@opentui/core' --filter '@opentui/solid' --filter '@opentui/react' --if-present test:nodejs", + "test:dist": "bun scripts/dist-test.ts --build packages/*/dist-test/*" }, "devDependencies": { + "commander": "^13.1.0", "oxfmt": "0.41.0", - "oxlint": "1.56.0" - } + "oxlint": "1.56.0", + "vitest": "4.1.3" + }, + "trustedDependencies": [ + "koffi" + ] } diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 000000000..5eeec7bfd --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +pathExcludePatterns = ["dist-test/**"] diff --git a/packages/core/dist-test/bun/index.test.ts b/packages/core/dist-test/bun/index.test.ts new file mode 100644 index 000000000..0217332fa --- /dev/null +++ b/packages/core/dist-test/bun/index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { createCliRenderer, TextRenderable } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { createRuntimePlugin } from "@opentui/core/runtime-plugin" + +const nativePackageName = `@opentui/core-${process.platform}-${process.arch}` + +describe("@opentui/core dist test (Bun)", () => { + test("imports core public entrypoints", async () => { + const core = await import("@opentui/core") + const testing = await import("@opentui/core/testing") + const runtimePlugin = await import("@opentui/core/runtime-plugin") + + expect(typeof core.createCliRenderer).toBe("function") + expect(typeof core.TextRenderable).toBe("function") + expect(typeof testing.createTestRenderer).toBe("function") + expect(typeof runtimePlugin.createRuntimePlugin).toBe("function") + }) + + test("loads the platform-native package", async () => { + const nativePackage = await import(nativePackageName) + expect(typeof nativePackage.default).toBe("string") + }) + + test("renders a frame with createTestRenderer", async () => { + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 20, + height: 4, + }) + + try { + const text = new TextRenderable(renderer, { content: "hello bun dist" }) + renderer.root.add(text) + await renderOnce() + + expect(captureCharFrame()).toMatch(/hello bun dist/) + } finally { + renderer.destroy() + } + }) + + test("createRuntimePlugin returns a valid plugin", () => { + const plugin = createRuntimePlugin() + expect(plugin).toBeDefined() + expect(typeof plugin.name).toBe("string") + expect(typeof plugin.setup).toBe("function") + }) +}) diff --git a/packages/core/dist-test/bun/package.json b/packages/core/dist-test/bun/package.json new file mode 100644 index 000000000..509644e54 --- /dev/null +++ b/packages/core/dist-test/bun/package.json @@ -0,0 +1,14 @@ +{ + "name": "@opentui/core-dist-test-bun", + "private": true, + "type": "module", + "engines": { + "bun": ">=1.3.0" + }, + "scripts": { + "test": "bun test index.test.ts" + }, + "dependencies": { + "@opentui/core": "*" + } +} diff --git a/packages/core/dist-test/nodejs/index.js b/packages/core/dist-test/nodejs/index.js new file mode 100644 index 000000000..6c6e6f022 --- /dev/null +++ b/packages/core/dist-test/nodejs/index.js @@ -0,0 +1,209 @@ +import assert from "node:assert/strict" +import process from "node:process" + +const nativePackageName = `@opentui/core-${process.platform}-${process.arch}` +const isNodeTest = process.env.NODE_TEST_CONTEXT !== undefined +const isMainModule = import.meta.main + +let corePromise +let testingPromise +let runtimePluginPromise + +const loadCore = async () => { + corePromise ??= import("@opentui/core") + return corePromise +} + +const loadTesting = async () => { + testingPromise ??= import("@opentui/core/testing") + return testingPromise +} + +const loadRuntimePlugin = async () => { + runtimePluginPromise ??= import("@opentui/core/runtime-plugin") + return runtimePluginPromise +} + +export async function createAsciiFontSelectionRoot(renderer) { + const { ASCIIFontRenderable, BoxRenderable, RGBA, TextRenderable } = await loadCore() + + renderer.setBackgroundColor("#0d1117") + + const root = new BoxRenderable(renderer, { + id: "ascii-font-dist-demo-root", + position: "absolute", + left: 1, + top: 1, + width: 76, + height: 20, + backgroundColor: "#161b22", + borderColor: "#50565d", + title: "ASCII Font Dist Demo", + titleAlignment: "center", + border: true, + }) + renderer.root.add(root) + + const subtitle = new TextRenderable(renderer, { + id: "ascii-font-dist-demo-subtitle", + content: "Packed Node.js consumer smoke test", + left: 2, + top: 1, + fg: "#f0f6fc", + }) + root.add(subtitle) + + const instructions = new TextRenderable(renderer, { + id: "ascii-font-dist-demo-instructions", + content: "Drag to select a font. Press C to clear. Press Ctrl+C to exit.", + left: 2, + top: 2, + fg: "#94a3b8", + }) + root.add(instructions) + + const tinyFont = new ASCIIFontRenderable(renderer, { + id: "ascii-font-dist-demo-tiny", + position: "absolute", + left: 2, + top: 4, + text: "NODE", + font: "tiny", + color: RGBA.fromInts(255, 215, 0, 255), + backgroundColor: RGBA.fromInts(0, 0, 32, 255), + selectionBg: "#4a5568", + selectionFg: "#ffffff", + }) + root.add(tinyFont) + + const blockFont = new ASCIIFontRenderable(renderer, { + id: "ascii-font-dist-demo-block", + position: "absolute", + left: 2, + top: 8, + text: "DIST", + font: "block", + color: [RGBA.fromInts(255, 120, 120, 255), RGBA.fromInts(120, 220, 255, 255)], + backgroundColor: RGBA.fromInts(0, 0, 32, 255), + selectionBg: "#4a5568", + selectionFg: "#ffffff", + }) + root.add(blockFont) + + const preview = new TextRenderable(renderer, { + id: "ascii-font-dist-demo-preview", + content: "Expected selection target: NODE", + left: 2, + top: 15, + fg: "#7dd3fc", + }) + root.add(preview) + + const statusText = new TextRenderable(renderer, { + id: "ascii-font-dist-demo-status", + content: "Selection: none", + left: 2, + top: 17, + fg: "#e6edf3", + }) + root.add(statusText) + + let statusMessage = "Selection: none" + const setStatusMessage = (message) => { + statusMessage = message + statusText.content = message + } + + renderer.on("selection", (selection) => { + const selectedText = selection?.getSelectedText() ?? "" + setStatusMessage(selectedText ? `Selection: ${selectedText}` : "Selection: empty") + }) + + renderer.keyInput.on("keypress", (event) => { + const key = event.sequence.toLowerCase() + if (key === "c") { + renderer.clearSelection() + setStatusMessage("Selection cleared") + } + }) + + return { + root, + fonts: [tinyFont, blockFont], + getStatusMessage: () => statusMessage, + destroy: () => { + renderer.clearSelection() + root.destroyRecursively() + }, + } +} + +if (isNodeTest) { + const { default: test } = await import("node:test") + + test("imports core public entrypoints", async () => { + const [core, testing, runtimePlugin] = await Promise.all([loadCore(), loadTesting(), loadRuntimePlugin()]) + + assert.equal(typeof core.createCliRenderer, "function") + assert.equal(typeof core.ASCIIFontRenderable, "function") + assert.equal(typeof testing.createTestRenderer, "function") + assert.equal(typeof runtimePlugin.createRuntimePlugin, "function") + }) + + test("loads the platform-native package", async () => { + try { + const nativePackage = await import(nativePackageName) + assert.equal(typeof nativePackage.default, "string") + } catch (error) { + assert.fail( + `Expected ${nativePackageName} to be installed for the dist test. ` + + `dist-test should install it automatically. Original error: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }) + + test("renders the ASCII font selection demo and supports selection", async () => { + const [{ createTestRenderer }] = await Promise.all([loadTesting()]) + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 80, + height: 24, + }) + + const demo = await createAsciiFontSelectionRoot(renderer) + + try { + await renderOnce() + + const firstFrame = captureCharFrame() + assert.match(firstFrame, /ASCII Font Dist Demo/) + assert.match(firstFrame, /Drag to select a font/) + assert.match(firstFrame, /Expected selection target: NODE/) + + const [tinyFont] = demo.fonts + renderer.startSelection(tinyFont, tinyFont.x, tinyFont.y) + renderer.updateSelection(tinyFont, tinyFont.x + tinyFont.width, tinyFont.y, { finishDragging: true }) + renderer.emit("selection", renderer.getSelection()) + + await renderOnce() + + assert.equal(renderer.getSelection()?.getSelectedText(), "NODE") + assert.equal(tinyFont.hasSelection(), true) + assert.equal(demo.getStatusMessage(), "Selection: NODE") + assert.match(captureCharFrame(), /Selection: NODE/) + } finally { + demo.destroy() + renderer.destroy() + } + }) +} + +if (isMainModule && !isNodeTest) { + const { createCliRenderer } = await loadCore() + const renderer = await createCliRenderer({ + targetFps: 30, + enableMouseMovement: true, + exitOnCtrlC: true, + }) + + await createAsciiFontSelectionRoot(renderer) +} diff --git a/packages/core/dist-test/nodejs/package.json b/packages/core/dist-test/nodejs/package.json new file mode 100644 index 000000000..db25c3870 --- /dev/null +++ b/packages/core/dist-test/nodejs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@opentui/core-dist-test-nodejs", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test index.js" + }, + "dependencies": { + "@opentui/core": "*" + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 530a62c62..b86c62023 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,15 +23,16 @@ "bench:text-table": "bun src/benchmark/text-table-benchmark.ts", "bench:ts": "bun src/benchmark/native-span-feed-benchmark.ts --suite=quick --json=src/benchmark/latest-quick-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=default --json=src/benchmark/latest-default-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=large --json=src/benchmark/latest-large-bench-run.json && bun src/benchmark/native-span-feed-benchmark.ts --suite=all --json=src/benchmark/latest-all-bench-run.json && bun src/benchmark/native-span-feed-async-benchmark.ts --json=src/benchmark/latest-async-bench-run.json", "publish": "bun scripts/publish.ts", - "test:js": "bun test", - "test": "bun run test:native && bun run test:js" + "test:bun": "bun test", + "test:nodejs": "npx vitest run", + "test:dist": "bun ../../scripts/dist-test.ts ./dist-test/*", + "test": "bun run test:native && bun run test:bun && bun run test:nodejs && bun run test:dist" }, "license": "MIT", "devDependencies": { "@types/bun": "latest", "@types/node": "^24.0.0", "@types/three": "0.177.0", - "commander": "^13.1.0", "typescript": "^5", "web-tree-sitter": "0.25.10" }, @@ -40,6 +41,8 @@ "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", + "string-width": "8.2.0", + "strip-ansi": "7.2.0", "yoga-layout": "3.2.1" }, "peerDependencies": { @@ -47,15 +50,17 @@ }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", - "bun-webgpu": "0.1.5", - "planck": "^1.4.2", - "three": "0.177.0", - "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-darwin-arm64": "0.1.97", - "@opentui/core-linux-x64": "0.1.97", + "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", + "@opentui/core-linux-x64": "0.1.97", + "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", - "@opentui/core-win32-arm64": "0.1.97" + "bun-webgpu": "0.1.5", + "koffi": "2.15.6", + "planck": "^1.4.2", + "three": "0.177.0", + "unsafe-pointer": "0.2.0" }, "exports": { ".": { @@ -70,6 +75,14 @@ "types": "./src/testing.ts", "import": "./src/testing.ts" }, + "./compat/runtime": { + "types": "./src/compat/runtime.ts", + "import": "./src/compat/runtime.ts" + }, + "./compat/testHelpers": { + "types": "./src/compat/testHelpers.ts", + "import": "./src/compat/testHelpers.ts" + }, "./runtime-plugin": { "types": "./src/runtime-plugin.ts", "import": "./src/runtime-plugin.ts" diff --git a/packages/core/scripts/build.ts b/packages/core/scripts/build.ts index 198b8d726..5b0b37506 100644 --- a/packages/core/scripts/build.ts +++ b/packages/core/scripts/build.ts @@ -1,9 +1,8 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process" +import { spawnSync, type SpawnSyncReturns } from "child_process" import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs" -import { dirname, join, resolve } from "path" -import { fileURLToPath } from "url" +import path, { dirname, join, resolve } from "path" import process from "process" -import path from "path" +import { fileURLToPath } from "url" interface Variant { platform: string @@ -136,11 +135,16 @@ if (buildNative) { continue } - const indexTsContent = `const module = await import("./${libraryFileName}", { with: { type: "file" } }) -const path = module.default -export default path; + const indexJsContent = `import { fileURLToPath } from "node:url" + +const path = fileURLToPath(new URL("./${libraryFileName}", import.meta.url)) + +export default path ` - writeFileSync(join(nativeDir, "index.ts"), indexTsContent) + const indexDtsContent = `declare const path: string +export default path;` + writeFileSync(join(nativeDir, "index.js"), indexJsContent) + writeFileSync(join(nativeDir, "index.d.ts"), indexDtsContent) writeFileSync( join(nativeDir, "package.json"), @@ -149,8 +153,8 @@ export default path; name: nativeName, version: packageJson.version, description: `Prebuilt ${platform}-${arch} binaries for ${packageJson.name}`, - main: "index.ts", - types: "index.ts", + main: "index.js", + types: "index.d.ts", license: packageJson.license, author: packageJson.author, homepage: packageJson.homepage, diff --git a/packages/core/src/3d/canvas.ts b/packages/core/src/3d/canvas.ts index be1507d99..ce862c510 100644 --- a/packages/core/src/3d/canvas.ts +++ b/packages/core/src/3d/canvas.ts @@ -1,12 +1,12 @@ +import { readFileSync } from "node:fs" import { GPUCanvasContextMock } from "bun-webgpu" import { RGBA } from "../lib/RGBA.js" import { SuperSampleType } from "./WGPURenderer.js" import type { OptimizedBuffer } from "../buffer.js" -import { toArrayBuffer } from "bun:ffi" +import { toArrayBuffer } from "../compat/ffi.js" import { Jimp } from "jimp" -// @ts-ignore -import shaderTemplate from "./shaders/supersampling.wgsl" with { type: "text" } +const shaderTemplate = readFileSync(new URL("./shaders/supersampling.wgsl", import.meta.url), "utf8") const WORKGROUP_SIZE = 4 const SUPERSAMPLING_COMPUTE_SHADER = shaderTemplate.replace(/\${WORKGROUP_SIZE}/g, WORKGROUP_SIZE.toString()) diff --git a/packages/core/src/NativeSpanFeed.ts b/packages/core/src/NativeSpanFeed.ts index 906083825..e1f3d8e50 100644 --- a/packages/core/src/NativeSpanFeed.ts +++ b/packages/core/src/NativeSpanFeed.ts @@ -1,4 +1,4 @@ -import { toArrayBuffer, type Pointer } from "bun:ffi" +import { toArrayBuffer, type Pointer } from "./compat/ffi.js" import { resolveRenderLib } from "./zig.js" import { SpanInfoStruct } from "./zig-structs.js" import type { GrowthPolicy, NativeSpanFeedOptions, NativeSpanFeedStats } from "./zig-structs.js" diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 993ba357c..b06d77795 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1648,6 +1648,17 @@ export class RootRenderable extends Renderable { this.renderList.length = 0 this.updateLayout(deltaTime, this.renderList) + // 2b. onSizeChange callbacks during updateLayout may dirty the yoga tree + // (e.g. ScrollBox hides a scrollbar). Re-layout so dimensions converge + // within this frame instead of relying on a deferred second render, which + // has different timing between Bun and Node.js (process.nextTick fires + // during an await in Bun but after it resolves in Node.js). + if (this.yogaNode.isDirty()) { + this.calculateLayout() + this.renderList.length = 0 + this.updateLayout(deltaTime, this.renderList) + } + // 3. Render all collected renderables this._ctx.clearHitGridScissorRects() for (let i = 1; i < this.renderList.length; i++) { diff --git a/packages/core/src/__snapshots__/buffer.test.ts.nodejs.snap b/packages/core/src/__snapshots__/buffer.test.ts.nodejs.snap new file mode 100644 index 000000000..a7399f140 --- /dev/null +++ b/packages/core/src/__snapshots__/buffer.test.ts.nodejs.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`OptimizedBuffer > snapshot tests with unicode encoding > should handle multiline text with unicode > Multiline unicode rendering 1`] = ` +"Hi 世界 +🌟 Star + + + +" +`; + +exports[`OptimizedBuffer > snapshot tests with unicode encoding > should render ASCII text correctly > ASCII text rendering 1`] = ` +"Hello + + + + +" +`; + +exports[`OptimizedBuffer > snapshot tests with unicode encoding > should render emoji text correctly > Emoji text rendering 1`] = ` +"Hi 👋 🌍 + + + + +" +`; diff --git a/packages/core/src/benchmark/native-span-feed-async-benchmark.ts b/packages/core/src/benchmark/native-span-feed-async-benchmark.ts index 6d711cb8e..836b6a36b 100644 --- a/packages/core/src/benchmark/native-span-feed-async-benchmark.ts +++ b/packages/core/src/benchmark/native-span-feed-async-benchmark.ts @@ -1,4 +1,4 @@ -import { dlopen, FFIType, suffix } from "bun:ffi" +import { dlopen, FFIType, suffix } from "../compat/ffi.js" import { setRenderLibPath } from "../zig.js" if (!process.env.NATIVE_SPAN_FEED_LIB) { diff --git a/packages/core/src/benchmark/native-span-feed-benchmark.ts b/packages/core/src/benchmark/native-span-feed-benchmark.ts index 1004ddb59..b0acd425b 100644 --- a/packages/core/src/benchmark/native-span-feed-benchmark.ts +++ b/packages/core/src/benchmark/native-span-feed-benchmark.ts @@ -1,4 +1,4 @@ -import { dlopen, FFIType, suffix } from "bun:ffi" +import { dlopen, FFIType, suffix } from "../compat/ffi.js" import { setRenderLibPath } from "../zig.js" if (!process.env.NATIVE_SPAN_FEED_LIB) { diff --git a/packages/core/src/buffer.ts b/packages/core/src/buffer.ts index ea32b45dc..16c05a9c4 100644 --- a/packages/core/src/buffer.ts +++ b/packages/core/src/buffer.ts @@ -1,6 +1,6 @@ import { RGBA } from "./lib/index.js" import { resolveRenderLib, type RenderLib } from "./zig.js" -import { type Pointer, toArrayBuffer, ptr } from "bun:ffi" +import { type Pointer, ptr, toArrayBuffer } from "./compat/ffi.js" import { type BorderStyle, type BorderSides, BorderCharArrays, parseBorderStyle } from "./lib/index.js" import { TargetChannel, type WidthMethod, type CapturedSpan, type CapturedLine } from "./types.js" import type { TextBufferView } from "./text-buffer-view.js" diff --git a/packages/core/src/compat/FFIType.ts b/packages/core/src/compat/FFIType.ts new file mode 100644 index 000000000..24f4fb745 --- /dev/null +++ b/packages/core/src/compat/FFIType.ts @@ -0,0 +1,323 @@ +/** Copy of bun:ffi#FFIType */ +export enum FFIType { + char = 0, + /** + * 8-bit signed integer + * + * Must be a value between -127 and 127 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * signed char + * char // on x64 & aarch64 macOS + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + int8_t = 1, + /** + * 8-bit signed integer + * + * Must be a value between -127 and 127 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * signed char + * char // on x64 & aarch64 macOS + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + i8 = 1, + + /** + * 8-bit unsigned integer + * + * Must be a value between 0 and 255 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * unsigned char + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + uint8_t = 2, + /** + * 8-bit unsigned integer + * + * Must be a value between 0 and 255 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * unsigned char + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + u8 = 2, + + /** + * 16-bit signed integer + * + * Must be a value between -32768 and 32767 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * in16_t + * short // on arm64 & x64 + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + int16_t = 3, + /** + * 16-bit signed integer + * + * Must be a value between -32768 and 32767 + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * in16_t + * short // on arm64 & x64 + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + i16 = 3, + + /** + * 16-bit unsigned integer + * + * Must be a value between 0 and 65535, inclusive. + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * uint16_t + * unsigned short // on arm64 & x64 + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + uint16_t = 4, + /** + * 16-bit unsigned integer + * + * Must be a value between 0 and 65535, inclusive. + * + * When passing to a FFI function (C ABI), type coercion is not performed. + * + * In C: + * ```c + * uint16_t + * unsigned short // on arm64 & x64 + * ``` + * + * In JavaScript: + * ```js + * var num = 0; + * ``` + */ + u16 = 4, + + /** + * 32-bit signed integer + */ + int32_t = 5, + + /** + * 32-bit signed integer + * + * Alias of {@link FFIType.int32_t} + */ + i32 = 5, + /** + * 32-bit signed integer + * + * The same as `int` in C + * + * ```c + * int + * ``` + */ + int = 5, + + /** + * 32-bit unsigned integer + * + * The same as `unsigned int` in C (on x64 & arm64) + * + * C: + * ```c + * unsigned int + * ``` + * JavaScript: + * ```js + * ptr(new Uint32Array(1)) + * ``` + */ + uint32_t = 6, + /** + * 32-bit unsigned integer + * + * Alias of {@link FFIType.uint32_t} + */ + u32 = 6, + + /** + * int64 is a 64-bit signed integer + */ + int64_t = 7, + /** + * i64 is a 64-bit signed integer + */ + i64 = 7, + + /** + * 64-bit unsigned integer + */ + uint64_t = 8, + /** + * 64-bit unsigned integer + */ + u64 = 8, + + /** + * IEEE-754 double precision float + */ + double = 9, + + /** + * Alias of {@link FFIType.double} + */ + f64 = 9, + + /** + * IEEE-754 single precision float + */ + float = 10, + + /** + * Alias of {@link FFIType.float} + */ + f32 = 10, + + /** + * Boolean value + * + * Must be `true` or `false`. `0` and `1` type coercion is not supported. + * + * In C, this corresponds to: + * ```c + * bool + * _Bool + * ``` + */ + bool = 11, + + /** + * Pointer value + * + * See {@link Bun.FFI.ptr} for more information + * + * In C: + * ```c + * void* + * ``` + * + * In JavaScript: + * ```js + * ptr(new Uint8Array(1)) + * ``` + */ + ptr = 12, + /** + * Pointer value + * + * alias of {@link FFIType.ptr} + */ + pointer = 12, + + /** + * void value + * + * void arguments are not supported + * + * void return type is the default return type + * + * In C: + * ```c + * void + * ``` + */ + void = 13, + + /** + * When used as a `returns`, this will automatically become a {@link CString}. + * + * When used in `args` it is equivalent to {@link FFIType.pointer} + */ + cstring = 14, + + /** + * Attempt to coerce `BigInt` into a `Number` if it fits. This improves performance + * but means you might get a `BigInt` or you might get a `number`. + * + * In C, this always becomes `int64_t` + * + * In JavaScript, this could be number or it could be BigInt, depending on what + * value is passed in. + */ + i64_fast = 15, + + /** + * Attempt to coerce `BigInt` into a `Number` if it fits. This improves performance + * but means you might get a `BigInt` or you might get a `number`. + * + * In C, this always becomes `uint64_t` + * + * In JavaScript, this could be number or it could be BigInt, depending on what + * value is passed in. + */ + u64_fast = 16, + function = 17, + + napi_env = 18, + napi_value = 19, + buffer = 20, +} diff --git a/packages/core/src/compat/Worker.ts b/packages/core/src/compat/Worker.ts new file mode 100644 index 000000000..3fc6228f9 --- /dev/null +++ b/packages/core/src/compat/Worker.ts @@ -0,0 +1 @@ +export const Worker = (globalThis.Worker ?? (await import("./nodejs/Worker.js")).Worker) as typeof globalThis.Worker diff --git a/packages/core/src/compat/__snapshots__/test.ts.snap b/packages/core/src/compat/__snapshots__/test.ts.snap new file mode 100644 index 000000000..091ddbfd3 --- /dev/null +++ b/packages/core/src/compat/__snapshots__/test.ts.snap @@ -0,0 +1,28 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`OptimizedBuffer snapshot tests with unicode encoding should render ASCII text correctly: ASCII text rendering 1`] = ` +"Hello + + + + +" +`; + +exports[`OptimizedBuffer snapshot tests with unicode encoding should render emoji text correctly: Emoji text rendering 1`] = ` +"Hi 👋 🌍 + + + + +" +`; + +exports[`OptimizedBuffer snapshot tests with unicode encoding should handle multiline text with unicode: Multiline unicode rendering 1`] = ` +"Hi 世界 +🌟 Star + + + +" +`; diff --git a/packages/core/src/compat/bun-ffi-structs.ts b/packages/core/src/compat/bun-ffi-structs.ts new file mode 100644 index 000000000..f41264921 --- /dev/null +++ b/packages/core/src/compat/bun-ffi-structs.ts @@ -0,0 +1,10 @@ +let mod: typeof import("./nodejs/bun-ffi-structs/index.js") + +if (process.versions.bun) { + mod = (await import("bun-ffi-structs")) as any +} else { + mod = await import("./nodejs/bun-ffi-structs/index.js") +} + +export const defineStruct = mod.defineStruct +export const defineEnum = mod.defineEnum diff --git a/packages/core/src/compat/ffi.test.ts b/packages/core/src/compat/ffi.test.ts new file mode 100644 index 000000000..c2b828109 --- /dev/null +++ b/packages/core/src/compat/ffi.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "bun:test" +import * as ffi from "./ffi.js" + +describe("ffi", () => { + it("can round-trip a Uint8Array", () => { + const array = new TextEncoder().encode("Hello, world!") + const pointer = ffi.ptr(array) + const newArrayBuffer = ffi.toArrayBuffer(pointer, 0, array.byteLength) + const newArray = new Uint8Array(newArrayBuffer) + + expect(newArray).toEqual(array) + expect(new TextDecoder().decode(newArrayBuffer)).toBe("Hello, world!") + }) + + it("aliases Pointer memory", () => { + const array = new TextEncoder().encode("Hello, world!") + const pointer = ffi.ptr(array) + const newArrayBuffer = ffi.toArrayBuffer(pointer, 0, array.byteLength) + const newArray = new Uint8Array(newArrayBuffer) + + expect(array[0]).not.toBe(0) + newArray[0] = 0 + expect(array[0]).toBe(0) + }) + + it("returns stable address", () => { + const array = new TextEncoder().encode("Hello, world!") + const pointer = ffi.ptr(array) + const newArrayBuffer = ffi.toArrayBuffer(pointer, 0, array.byteLength) + const newArray = new Uint8Array(newArrayBuffer) + + expect(pointer).toBe(ffi.ptr(newArray)) + expect(pointer).toBe(ffi.ptr(newArrayBuffer)) + }) +}) diff --git a/packages/core/src/compat/ffi.ts b/packages/core/src/compat/ffi.ts new file mode 100644 index 000000000..e0591dd7d --- /dev/null +++ b/packages/core/src/compat/ffi.ts @@ -0,0 +1,174 @@ +import { FFIType } from "./FFIType.js" + +export { FFIType } + +export type Pointer = number & { __pointer__: null } + +interface FFITypeStringToType { + ["char"]: FFIType.char + ["int8_t"]: FFIType.int8_t + ["i8"]: FFIType.i8 + ["uint8_t"]: FFIType.uint8_t + ["u8"]: FFIType.u8 + ["int16_t"]: FFIType.int16_t + ["i16"]: FFIType.i16 + ["uint16_t"]: FFIType.uint16_t + ["u16"]: FFIType.u16 + ["int32_t"]: FFIType.int32_t + ["i32"]: FFIType.i32 + ["int"]: FFIType.int + ["uint32_t"]: FFIType.uint32_t + ["u32"]: FFIType.u32 + ["int64_t"]: FFIType.int64_t + ["i64"]: FFIType.i64 + ["uint64_t"]: FFIType.uint64_t + ["u64"]: FFIType.u64 + ["double"]: FFIType.double + ["f64"]: FFIType.f64 + ["float"]: FFIType.float + ["f32"]: FFIType.f32 + ["bool"]: FFIType.bool + ["ptr"]: FFIType.ptr + ["pointer"]: FFIType.pointer + ["void"]: FFIType.void + ["cstring"]: FFIType.cstring + ["function"]: FFIType.function + ["usize"]: FFIType.uint64_t + ["callback"]: FFIType.function + ["napi_env"]: FFIType.napi_env + ["napi_value"]: FFIType.napi_value + ["buffer"]: FFIType.buffer +} + +export type FFITypeOrString = FFIType | keyof FFITypeStringToType + +export interface FFIFunction { + readonly args?: readonly FFITypeOrString[] + readonly returns?: FFITypeOrString + readonly ptr?: Pointer | bigint + readonly threadsafe?: boolean +} + +type Symbols = Readonly> +type ToFFIType = T extends FFIType + ? T + : T extends keyof FFITypeStringToType + ? FFITypeStringToType[T] + : never +type NumericFFIType = + | FFIType.char + | FFIType.int8_t + | FFIType.i8 + | FFIType.uint8_t + | FFIType.u8 + | FFIType.int16_t + | FFIType.i16 + | FFIType.uint16_t + | FFIType.u16 + | FFIType.int32_t + | FFIType.i32 + | FFIType.int + | FFIType.uint32_t + | FFIType.u32 + | FFIType.double + | FFIType.f64 + | FFIType.float + | FFIType.f32 +type BigIntArgFFIType = + | FFIType.int64_t + | FFIType.i64 + | FFIType.uint64_t + | FFIType.u64 + | FFIType.i64_fast + | FFIType.u64_fast +type BigIntReturnFFIType = FFIType.int64_t | FFIType.i64 | FFIType.uint64_t | FFIType.u64 +type PointerLike = NodeJS.TypedArray | DataView | Pointer | null +type BufferLike = NodeJS.TypedArray | DataView +type FFIArgValue = T extends NumericFFIType + ? number + : T extends BigIntArgFFIType + ? number | bigint + : T extends FFIType.bool + ? boolean + : T extends FFIType.ptr | FFIType.pointer | FFIType.cstring + ? PointerLike + : T extends FFIType.void + ? undefined + : T extends FFIType.function + ? Pointer | JSCallback + : T extends FFIType.napi_env | FFIType.napi_value + ? unknown + : T extends FFIType.buffer + ? BufferLike + : never +type FFIReturnValue = T extends NumericFFIType + ? number + : T extends BigIntReturnFFIType + ? bigint + : T extends FFIType.i64_fast | FFIType.u64_fast + ? number | bigint + : T extends FFIType.bool + ? boolean + : T extends FFIType.ptr | FFIType.pointer | FFIType.cstring | FFIType.function + ? Pointer | null + : T extends FFIType.void + ? undefined + : T extends FFIType.napi_env | FFIType.napi_value + ? unknown + : T extends FFIType.buffer + ? BufferLike + : never + +declare const FFIFunctionCallableSymbol: unique symbol + +export type ConvertFns = { + [K in keyof Fns]: { + ( + ...args: Fns[K]["args"] extends infer A extends readonly FFITypeOrString[] + ? { [L in keyof A]: FFIArgValue> } + : [unknown] extends [Fns[K]["args"]] + ? [] + : never + ): [unknown] extends [Fns[K]["returns"]] ? undefined : FFIReturnValue>> + __ffi_function_callable: typeof FFIFunctionCallableSymbol + } +} + +export interface Library { + symbols: ConvertFns + close(): void +} + +export interface JSCallback { + readonly ptr: Pointer | null + readonly threadsafe: boolean + close(): void +} + +export interface JSCallbackConstructor { + new (callback: (...args: any[]) => any, definition: FFIFunction): JSCallback +} + +export type DlopenFunction = >(name: string | URL, symbols: Fns) => Library +export type PtrFunction = (value: ArrayBufferLike | ArrayBufferView) => Pointer +export type ToArrayBufferFunction = (pointer: Pointer, offset: number | undefined, length: number) => ArrayBuffer + +type FfiModule = { + JSCallback: JSCallbackConstructor + dlopen: DlopenFunction + ptr: PtrFunction + suffix: string + toArrayBuffer: ToArrayBufferFunction +} + +const ffiModule: FfiModule = ( + process.versions.bun ? await import("bun:ffi") : await import("./nodejs/ffi.js") +) as FfiModule + +export const JSCallback = ffiModule.JSCallback +export const dlopen = ffiModule.dlopen +export const ptr = ffiModule.ptr +export const suffix = ffiModule.suffix +export const toArrayBuffer = ffiModule.toArrayBuffer + +export const __url = import.meta.url diff --git a/packages/core/src/compat/nodejs/Worker.ts b/packages/core/src/compat/nodejs/Worker.ts new file mode 100644 index 000000000..5069d7df8 --- /dev/null +++ b/packages/core/src/compat/nodejs/Worker.ts @@ -0,0 +1,79 @@ +import { existsSync } from "node:fs" +import { extname, isAbsolute, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { Worker as NodeWorker } from "node:worker_threads" + +type MessageEventLike = { data: T } +type ErrorEventLike = { message: string } + +const ownExtension = extname(import.meta.url) +const knownProtocolRegex = /^(file|data|node):/ + +function resolveWorkerTarget(url: string | URL): string { + if (url instanceof URL) { + return url.href + } + + // allowing any : will confuse windows absolute path starting + // with a drive letter with a valid url. + if (knownProtocolRegex.test(url)) { + return url + } + + return pathToFileURL(url).href +} + +function normalizeExtension(specifier: string): string +function normalizeExtension(specifier: URL): URL +function normalizeExtension(specifier: string | URL): string | URL { + if (existsSync(specifier)) { + return specifier + } + + const stringSpecifier = String(specifier) + const extension = extname(stringSpecifier) + if (extension === ownExtension) { + return specifier + } + + const newSpecifier = stringSpecifier.slice(0, -extension.length) + ownExtension + if (specifier instanceof URL) { + return new URL(newSpecifier) + } + return newSpecifier +} + +let trampoline: URL | undefined +let registerJs: string | URL | undefined + +export class Worker extends NodeWorker { + onmessage: ((event: MessageEventLike) => void) | null = null + onerror: ((event: ErrorEventLike) => void) | null = null + + constructor(url: string | URL) { + let execArgv = process.execArgv + if (import.meta.url.endsWith(".ts")) { + registerJs ??= normalizeExtension(new URL("./registerResolveJs.js", import.meta.url)) + const registerJsArg = `--import=${fileURLToPath(registerJs)}` + if (!execArgv.includes(registerJsArg)) { + execArgv = [...execArgv, registerJsArg] + } + } + + trampoline ??= normalizeExtension(new URL("./trampoline.worker.js", import.meta.url)) + super(trampoline, { + workerData: { + targetUrl: resolveWorkerTarget(url), + }, + execArgv, + }) + + this.on("message", (data: unknown) => { + this.onmessage?.({ data }) + }) + + this.on("error", (error: Error) => { + this.onerror?.(error) + }) + } +} diff --git a/packages/core/src/compat/nodejs/bun-ffi-structs/error.d.ts b/packages/core/src/compat/nodejs/bun-ffi-structs/error.d.ts new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/core/src/compat/nodejs/bun-ffi-structs/error.d.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/core/src/compat/nodejs/bun-ffi-structs/index.d.ts b/packages/core/src/compat/nodejs/bun-ffi-structs/index.d.ts new file mode 100644 index 000000000..3d605ee91 --- /dev/null +++ b/packages/core/src/compat/nodejs/bun-ffi-structs/index.d.ts @@ -0,0 +1,2 @@ +export * from "./structs_ffi" +export * from "./types" diff --git a/packages/core/src/compat/nodejs/bun-ffi-structs/index.js b/packages/core/src/compat/nodejs/bun-ffi-structs/index.js new file mode 100644 index 000000000..d08bff23d --- /dev/null +++ b/packages/core/src/compat/nodejs/bun-ffi-structs/index.js @@ -0,0 +1,649 @@ +// src/structs_ffi.ts +import { ptr, toArrayBuffer } from "../ffi.js" +function fatalError(...args) { + const message = args.join(" ") + console.error("FATAL ERROR:", message) + throw new Error(message) +} +var pointerSize = process.arch === "x64" || process.arch === "arm64" ? 8 : 4 +var typeSizes = { + u8: 1, + bool_u8: 1, + bool_u32: 4, + u16: 2, + i16: 2, + u32: 4, + u64: 8, + f32: 4, + f64: 8, + pointer: pointerSize, + i32: 4, +} +var primitiveKeys = Object.keys(typeSizes) +function isPrimitiveType(type) { + return typeof type === "string" && primitiveKeys.includes(type) +} +var typeAlignments = { ...typeSizes } +var typeGetters = { + u8: (view, offset) => view.getUint8(offset), + bool_u8: (view, offset) => Boolean(view.getUint8(offset)), + bool_u32: (view, offset) => Boolean(view.getUint32(offset, true)), + u16: (view, offset) => view.getUint16(offset, true), + i16: (view, offset) => view.getInt16(offset, true), + u32: (view, offset) => view.getUint32(offset, true), + u64: (view, offset) => view.getBigUint64(offset, true), + f32: (view, offset) => view.getFloat32(offset, true), + f64: (view, offset) => view.getFloat64(offset, true), + i32: (view, offset) => view.getInt32(offset, true), + pointer: (view, offset) => + pointerSize === 8 ? view.getBigUint64(offset, true) : BigInt(view.getUint32(offset, true)), +} +function objectPtr() { + return { + __type: "objectPointer", + } +} +function isObjectPointerDef(type) { + return typeof type === "object" && type !== null && type.__type === "objectPointer" +} +function allocStruct(structDef, options) { + const buffer = new ArrayBuffer(structDef.size) + const view = new DataView(buffer) + const result = { buffer, view } + const { pack: pointerPacker } = primitivePackers("pointer") + if (options?.lengths) { + const subBuffers = {} + for (const [arrayFieldName, length] of Object.entries(options.lengths)) { + const arrayMeta = structDef.arrayFields.get(arrayFieldName) + if (!arrayMeta) { + throw new Error(`Field '${arrayFieldName}' is not an array field with a lengthOf field`) + } + const subBuffer = new ArrayBuffer(length * arrayMeta.elementSize) + subBuffers[arrayFieldName] = subBuffer + const pointer = length > 0 ? ptr(subBuffer) : null + pointerPacker(view, arrayMeta.arrayOffset, pointer) + arrayMeta.lengthPack(view, arrayMeta.lengthOffset, length) + } + if (Object.keys(subBuffers).length > 0) { + result.subBuffers = subBuffers + } + } + return result +} +function alignOffset(offset, align) { + return (offset + (align - 1)) & ~(align - 1) +} +function enumTypeError(value) { + throw new TypeError(`Invalid enum value: ${value}`) +} +function defineEnum(mapping, base = "u32") { + const reverse = Object.fromEntries(Object.entries(mapping).map(([k, v]) => [v, k])) + return { + __type: "enum", + type: base, + to(value) { + return typeof value === "number" ? value : (mapping[value] ?? enumTypeError(String(value))) + }, + from(value) { + return reverse[value] ?? enumTypeError(String(value)) + }, + enum: mapping, + } +} +function isEnum(type) { + return typeof type === "object" && type.__type === "enum" +} +function isStruct(type) { + return typeof type === "object" && type.__type === "struct" +} +function primitivePackers(type) { + let pack + let unpack + switch (type) { + case "u8": + pack = (view, off, val) => view.setUint8(off, val) + unpack = (view, off) => view.getUint8(off) + break + case "bool_u8": + pack = (view, off, val) => view.setUint8(off, val ? 1 : 0) + unpack = (view, off) => Boolean(view.getUint8(off)) + break + case "bool_u32": + pack = (view, off, val) => view.setUint32(off, val ? 1 : 0, true) + unpack = (view, off) => Boolean(view.getUint32(off, true)) + break + case "u16": + pack = (view, off, val) => view.setUint16(off, val, true) + unpack = (view, off) => view.getUint16(off, true) + break + case "i16": + pack = (view, off, val) => view.setInt16(off, val, true) + unpack = (view, off) => view.getInt16(off, true) + break + case "u32": + pack = (view, off, val) => view.setUint32(off, val, true) + unpack = (view, off) => view.getUint32(off, true) + break + case "i32": + pack = (view, off, val) => view.setInt32(off, val, true) + unpack = (view, off) => view.getInt32(off, true) + break + case "u64": + pack = (view, off, val) => view.setBigUint64(off, BigInt(val), true) + unpack = (view, off) => view.getBigUint64(off, true) + break + case "f32": + pack = (view, off, val) => view.setFloat32(off, val, true) + unpack = (view, off) => view.getFloat32(off, true) + break + case "f64": + pack = (view, off, val) => view.setFloat64(off, val, true) + unpack = (view, off) => view.getFloat64(off, true) + break + case "pointer": + pack = (view, off, val) => { + pointerSize === 8 + ? view.setBigUint64(off, val ? BigInt(val) : 0n, true) + : view.setUint32(off, val ? Number(val) : 0, true) + } + unpack = (view, off) => { + const bint = pointerSize === 8 ? view.getBigUint64(off, true) : BigInt(view.getUint32(off, true)) + return Number(bint) + } + break + default: + fatalError(`Unsupported primitive type: ${type}`) + } + return { pack, unpack } +} +var { pack: pointerPacker, unpack: pointerUnpacker } = primitivePackers("pointer") +function packObjectArray(val) { + const buffer = new ArrayBuffer(val.length * pointerSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < val.length; i++) { + const instance = val[i] + const ptrValue = instance?.ptr ?? null + pointerPacker(bufferView, i * pointerSize, ptrValue) + } + return bufferView +} +var encoder = new TextEncoder() +var decoder = new TextDecoder() +function defineStruct(fields, structDefOptions) { + let offset = 0 + let maxAlign = 1 + const layout = [] + const lengthOfFields = {} + const lengthOfRequested = [] + const arrayFieldsMetadata = {} + for (const [name, typeOrStruct, options = {}] of fields) { + if (options.condition && !options.condition()) { + continue + } + let size = 0, + align = 0 + let pack + let unpack + let needsLengthOf = false + let lengthOfDef = null + if (isPrimitiveType(typeOrStruct)) { + size = typeSizes[typeOrStruct] + align = typeAlignments[typeOrStruct] + ;({ pack, unpack } = primitivePackers(typeOrStruct)) + } else if (typeof typeOrStruct === "string" && typeOrStruct === "cstring") { + size = pointerSize + align = pointerSize + pack = (view, off, val) => { + const bufPtr = val ? ptr(encoder.encode(val + "\x00")) : null + pointerPacker(view, off, bufPtr) + } + unpack = (view, off) => { + const ptrVal = pointerUnpacker(view, off) + return ptrVal + } + } else if (typeof typeOrStruct === "string" && typeOrStruct === "char*") { + size = pointerSize + align = pointerSize + pack = (view, off, val) => { + const bufPtr = val ? ptr(encoder.encode(val)) : null + pointerPacker(view, off, bufPtr) + } + unpack = (view, off) => { + const ptrVal = pointerUnpacker(view, off) + return ptrVal + } + needsLengthOf = true + } else if (isEnum(typeOrStruct)) { + const base = typeOrStruct.type + size = typeSizes[base] + align = typeAlignments[base] + const { pack: packEnum } = primitivePackers(base) + pack = (view, off, val) => { + const num = typeOrStruct.to(val) + packEnum(view, off, num) + } + unpack = (view, off) => { + const raw = typeGetters[base](view, off) + return typeOrStruct.from(raw) + } + } else if (isStruct(typeOrStruct)) { + if (options.asPointer === true) { + size = pointerSize + align = pointerSize + pack = (view, off, val, obj, options2) => { + if (!val) { + pointerPacker(view, off, null) + return + } + const nestedBuf = typeOrStruct.pack(val, options2) + pointerPacker(view, off, ptr(nestedBuf)) + } + unpack = (view, off) => { + throw new Error("Not implemented yet") + } + } else { + size = typeOrStruct.size + align = typeOrStruct.align + pack = (view, off, val, obj, options2) => { + const nestedBuf = typeOrStruct.pack(val, options2) + const nestedView = new Uint8Array(nestedBuf) + const dView = new Uint8Array(view.buffer) + dView.set(nestedView, off) + } + unpack = (view, off) => { + const slice = view.buffer.slice(off, off + size) + return typeOrStruct.unpack(slice) + } + } + } else if (isObjectPointerDef(typeOrStruct)) { + size = pointerSize + align = pointerSize + pack = (view, off, value) => { + const ptrValue = value?.ptr ?? null + if (ptrValue === undefined) { + console.warn( + `Field '${name}' expected object with '.ptr' property, but got undefined pointer value from:`, + value, + ) + pointerPacker(view, off, null) + } else { + pointerPacker(view, off, ptrValue) + } + } + unpack = (view, off) => { + return pointerUnpacker(view, off) + } + } else if (Array.isArray(typeOrStruct) && typeOrStruct.length === 1 && typeOrStruct[0] !== undefined) { + const [def] = typeOrStruct + size = pointerSize + align = pointerSize + let arrayElementSize + if (isEnum(def)) { + arrayElementSize = typeSizes[def.type] + pack = (view, off, val, obj) => { + if (!val || val.length === 0) { + pointerPacker(view, off, null) + return + } + const buffer = new ArrayBuffer(val.length * arrayElementSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < val.length; i++) { + const num = def.to(val[i]) + bufferView.setUint32(i * arrayElementSize, num, true) + } + pointerPacker(view, off, ptr(buffer)) + } + unpack = null + needsLengthOf = true + lengthOfDef = def + } else if (isStruct(def)) { + arrayElementSize = def.size + pack = (view, off, val, obj, options2) => { + if (!val || val.length === 0) { + pointerPacker(view, off, null) + return + } + const buffer = new ArrayBuffer(val.length * arrayElementSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < val.length; i++) { + def.packInto(val[i], bufferView, i * arrayElementSize, options2) + } + pointerPacker(view, off, ptr(buffer)) + } + unpack = (view, off) => { + throw new Error("Not implemented yet") + } + } else if (isPrimitiveType(def)) { + arrayElementSize = typeSizes[def] + const { pack: primitivePack } = primitivePackers(def) + pack = (view, off, val) => { + if (!val || val.length === 0) { + pointerPacker(view, off, null) + return + } + const buffer = new ArrayBuffer(val.length * arrayElementSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < val.length; i++) { + primitivePack(bufferView, i * arrayElementSize, val[i]) + } + pointerPacker(view, off, ptr(buffer)) + } + unpack = null + needsLengthOf = true + lengthOfDef = def + } else if (isObjectPointerDef(def)) { + arrayElementSize = pointerSize + pack = (view, off, val) => { + if (!val || val.length === 0) { + pointerPacker(view, off, null) + return + } + const packedView = packObjectArray(val) + pointerPacker(view, off, ptr(packedView.buffer)) + } + unpack = () => { + throw new Error("not implemented yet") + } + } else { + throw new Error(`Unsupported array element type for ${name}: ${JSON.stringify(def)}`) + } + const lengthOfField = Object.values(lengthOfFields).find((f) => f.lengthOf === name) + if (lengthOfField && isPrimitiveType(lengthOfField.type)) { + const { pack: lengthPack } = primitivePackers(lengthOfField.type) + arrayFieldsMetadata[name] = { + elementSize: arrayElementSize, + arrayOffset: offset, + lengthOffset: lengthOfField.offset, + lengthPack, + } + } + } else { + throw new Error(`Unsupported field type for ${name}: ${JSON.stringify(typeOrStruct)}`) + } + offset = alignOffset(offset, align) + if (options.unpackTransform) { + const originalUnpack = unpack + unpack = (view, off) => options.unpackTransform(originalUnpack(view, off)) + } + if (options.packTransform) { + const originalPack = pack + pack = (view, off, val, obj, packOptions) => originalPack(view, off, options.packTransform(val), obj, packOptions) + } + if (options.optional) { + const originalPack = pack + if (isStruct(typeOrStruct) && !options.asPointer) { + pack = (view, off, val, obj, packOptions) => { + if (val || options.mapOptionalInline) { + originalPack(view, off, val, obj, packOptions) + } + } + } else { + pack = (view, off, val, obj, packOptions) => originalPack(view, off, val ?? 0, obj, packOptions) + } + } + if (options.lengthOf) { + const originalPack = pack + pack = (view, off, val, obj, packOptions) => { + const targetValue = obj[options.lengthOf] + let length = 0 + if (targetValue) { + if (typeof targetValue === "string") { + length = Buffer.byteLength(targetValue) + } else { + length = targetValue.length + } + } + return originalPack(view, off, length, obj, packOptions) + } + } + let validateFunctions + if (options.validate) { + validateFunctions = Array.isArray(options.validate) ? options.validate : [options.validate] + } + const layoutField = { + name, + offset, + size, + align, + validate: validateFunctions, + optional: !!options.optional || !!options.lengthOf || options.default !== undefined, + default: options.default, + pack, + unpack, + type: typeOrStruct, + lengthOf: options.lengthOf, + } + layout.push(layoutField) + if (options.lengthOf) { + lengthOfFields[options.lengthOf] = layoutField + } + if (needsLengthOf) { + const def = typeof typeOrStruct === "string" && typeOrStruct === "char*" ? "char*" : lengthOfDef + if (!def) fatalError(`Internal error: needsLengthOf=true but def is null for ${name}`) + lengthOfRequested.push({ requester: layoutField, def }) + } + offset += size + maxAlign = Math.max(maxAlign, align) + } + for (const { requester, def } of lengthOfRequested) { + const lengthOfField = lengthOfFields[requester.name] + if (!lengthOfField) { + if (def === "char*") { + continue + } + throw new Error(`lengthOf field not found for array field ${requester.name}`) + } + if (def === "char*") { + requester.unpack = (view, off) => { + const ptrAddress = pointerUnpacker(view, off) + const length = lengthOfField.unpack(view, lengthOfField.offset) + if (ptrAddress === 0) { + return null + } + const byteLength = typeof length === "bigint" ? Number(length) : length + if (byteLength === 0) { + return "" + } + const buffer = toArrayBuffer(ptrAddress, 0, byteLength) + return decoder.decode(buffer) + } + } else if (isPrimitiveType(def)) { + const elemSize = typeSizes[def] + const { unpack: primitiveUnpack } = primitivePackers(def) + requester.unpack = (view, off) => { + const result = [] + const length = lengthOfField.unpack(view, lengthOfField.offset) + const ptrAddress = pointerUnpacker(view, off) + if (ptrAddress === 0n && length > 0) { + throw new Error(`Array field ${requester.name} has null pointer but length ${length}.`) + } + if (ptrAddress === 0n || length === 0) { + return [] + } + const buffer = toArrayBuffer(ptrAddress, 0, length * elemSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < length; i++) { + result.push(primitiveUnpack(bufferView, i * elemSize)) + } + return result + } + } else { + const elemSize = def.type === "u32" ? 4 : 8 + requester.unpack = (view, off) => { + const result = [] + const length = lengthOfField.unpack(view, lengthOfField.offset) + const ptrAddress = pointerUnpacker(view, off) + if (ptrAddress === 0n && length > 0) { + throw new Error(`Array field ${requester.name} has null pointer but length ${length}.`) + } + if (ptrAddress === 0n || length === 0) { + return [] + } + const buffer = toArrayBuffer(ptrAddress, 0, length * elemSize) + const bufferView = new DataView(buffer) + for (let i = 0; i < length; i++) { + result.push(def.from(bufferView.getUint32(i * elemSize, true))) + } + return result + } + } + } + const totalSize = alignOffset(offset, maxAlign) + const description = layout.map((f) => ({ + name: f.name, + offset: f.offset, + size: f.size, + align: f.align, + optional: f.optional, + type: f.type, + lengthOf: f.lengthOf, + })) + const layoutByName = new Map(description.map((f) => [f.name, f])) + const arrayFields = new Map(Object.entries(arrayFieldsMetadata)) + return { + __type: "struct", + size: totalSize, + align: maxAlign, + hasMapValue: !!structDefOptions?.mapValue, + layoutByName, + arrayFields, + pack(obj, options) { + const buf = new ArrayBuffer(totalSize) + const view = new DataView(buf) + let mappedObj = obj + if (structDefOptions?.mapValue) { + mappedObj = structDefOptions.mapValue(obj) + } + for (const field of layout) { + const value = mappedObj[field.name] ?? field.default + if (!field.optional && value === undefined) { + fatalError(`Packing non-optional field '${field.name}' but value is undefined (and no default provided)`) + } + if (field.validate) { + for (const validateFn of field.validate) { + validateFn(value, field.name, { + hints: options?.validationHints, + input: mappedObj, + }) + } + } + field.pack(view, field.offset, value, mappedObj, options) + } + return view.buffer + }, + packInto(obj, view, offset2, options) { + let mappedObj = obj + if (structDefOptions?.mapValue) { + mappedObj = structDefOptions.mapValue(obj) + } + for (const field of layout) { + const value = mappedObj[field.name] ?? field.default + if (!field.optional && value === undefined) { + console.warn( + `packInto missing value for non-optional field '${field.name}' at offset ${offset2 + field.offset}. Writing default or zero.`, + ) + } + if (field.validate) { + for (const validateFn of field.validate) { + validateFn(value, field.name, { + hints: options?.validationHints, + input: mappedObj, + }) + } + } + field.pack(view, offset2 + field.offset, value, mappedObj, options) + } + }, + unpack(buf) { + if (buf.byteLength < totalSize) { + fatalError(`Buffer size (${buf.byteLength}) is smaller than struct size (${totalSize}) for unpacking.`) + } + const view = new DataView(buf) + const result = structDefOptions?.default ? { ...structDefOptions.default } : {} + for (const field of layout) { + if (!field.unpack) { + continue + } + try { + result[field.name] = field.unpack(view, field.offset) + } catch (e) { + console.error(`Error unpacking field '${field.name}' at offset ${field.offset}:`, e) + throw e + } + } + if (structDefOptions?.reduceValue) { + return structDefOptions.reduceValue(result) + } + return result + }, + packList(objects, options) { + if (objects.length === 0) { + return new ArrayBuffer(0) + } + const buffer = new ArrayBuffer(totalSize * objects.length) + const view = new DataView(buffer) + for (let i = 0; i < objects.length; i++) { + let mappedObj = objects[i] + if (structDefOptions?.mapValue) { + mappedObj = structDefOptions.mapValue(objects[i]) + } + for (const field of layout) { + const value = mappedObj[field.name] ?? field.default + if (!field.optional && value === undefined) { + fatalError( + `Packing non-optional field '${field.name}' at index ${i} but value is undefined (and no default provided)`, + ) + } + if (field.validate) { + for (const validateFn of field.validate) { + validateFn(value, field.name, { + hints: options?.validationHints, + input: mappedObj, + }) + } + } + field.pack(view, i * totalSize + field.offset, value, mappedObj, options) + } + } + return buffer + }, + unpackList(buf, count) { + if (count === 0) { + return [] + } + const expectedSize = totalSize * count + if (buf.byteLength < expectedSize) { + fatalError( + `Buffer size (${buf.byteLength}) is smaller than expected size (${expectedSize}) for unpacking ${count} structs.`, + ) + } + const view = new DataView(buf) + const results = [] + for (let i = 0; i < count; i++) { + const offset2 = i * totalSize + const result = structDefOptions?.default ? { ...structDefOptions.default } : {} + for (const field of layout) { + if (!field.unpack) { + continue + } + try { + result[field.name] = field.unpack(view, offset2 + field.offset) + } catch (e) { + console.error(`Error unpacking field '${field.name}' at index ${i}, offset ${offset2 + field.offset}:`, e) + throw e + } + } + if (structDefOptions?.reduceValue) { + results.push(structDefOptions.reduceValue(result)) + } else { + results.push(result) + } + } + return results + }, + describe() { + return description + }, + } +} +export { pointerSize, packObjectArray, objectPtr, defineStruct, defineEnum, allocStruct } diff --git a/packages/core/src/compat/nodejs/bun-ffi-structs/structs_ffi.d.ts b/packages/core/src/compat/nodejs/bun-ffi-structs/structs_ffi.d.ts new file mode 100644 index 000000000..547c79427 --- /dev/null +++ b/packages/core/src/compat/nodejs/bun-ffi-structs/structs_ffi.d.ts @@ -0,0 +1,57 @@ +import type { + PrimitiveType, + PointyObject, + ObjectPointerDef, + AllocStructOptions, + AllocStructResult, + EnumDef, + StructDef, + StructDefOptions, + DefineStructReturnType, +} from "./types" +export declare const pointerSize: number +/** + * Type helper for creating object pointers for structs. + */ +export declare function objectPtr(): ObjectPointerDef +export declare function allocStruct(structDef: StructDef, options?: AllocStructOptions): AllocStructResult +export declare function defineEnum>( + mapping: T, + base?: Exclude, +): EnumDef +type ValidationFunction = ( + value: any, + fieldName: string, + options: { + hints?: any + input?: any + }, +) => void | never +interface StructFieldOptions { + optional?: boolean + mapOptionalInline?: boolean + unpackTransform?: (value: any) => any + packTransform?: (value: any) => any + lengthOf?: string + asPointer?: boolean + default?: any + condition?: () => boolean + validate?: ValidationFunction | ValidationFunction[] +} +type StructField = + | readonly [string, PrimitiveType, StructFieldOptions?] + | readonly [string, EnumDef, StructFieldOptions?] + | readonly [string, StructDef, StructFieldOptions?] + | readonly [string, "cstring" | "char*", StructFieldOptions?] + | readonly [string, ObjectPointerDef, StructFieldOptions?] + | readonly [ + string, + readonly [EnumDef | StructDef | PrimitiveType | ObjectPointerDef], + StructFieldOptions?, + ] +export declare function packObjectArray(val: (PointyObject | null)[]): DataView +export declare function defineStruct< + const Fields extends readonly StructField[], + const Opts extends StructDefOptions = {}, +>(fields: Fields & StructField[], structDefOptions?: Opts): DefineStructReturnType +export {} diff --git a/packages/core/src/compat/nodejs/bun-ffi-structs/types.d.ts b/packages/core/src/compat/nodejs/bun-ffi-structs/types.d.ts new file mode 100644 index 000000000..3f08c782b --- /dev/null +++ b/packages/core/src/compat/nodejs/bun-ffi-structs/types.d.ts @@ -0,0 +1,239 @@ +import type { Pointer } from "../ffi.js" +export type PrimitiveType = + | "u8" + | "u16" + | "u32" + | "u64" + | "f32" + | "f64" + | "pointer" + | "i32" + | "i16" + | "bool_u8" + | "bool_u32" +export interface PointyObject { + ptr: Pointer | number | bigint | null +} +export interface ObjectPointerDef { + __type: "objectPointer" +} +type Prettify = { + [K in keyof T]: T[K] +} & {} +export type Simplify = T extends (...args: any[]) => any ? T : T extends object ? Prettify : T +export type PrimitiveToTSType = T extends "u8" | "u16" | "u32" | "i16" | "i32" | "f32" | "f64" + ? number + : T extends "u64" + ? bigint | number + : T extends "bool_u8" | "bool_u32" + ? boolean + : T extends "pointer" + ? number | bigint + : never +type FieldDefInputType = Options extends { + packTransform: (value: infer T) => any +} + ? T + : Def extends PrimitiveType + ? PrimitiveToTSType + : Def extends "cstring" | "char*" + ? string | null + : Def extends EnumDef + ? keyof E + : Def extends StructDef + ? InputType + : Def extends ObjectPointerDef + ? T | null + : Def extends readonly [infer InnerDef] + ? InnerDef extends PrimitiveType + ? Iterable> + : InnerDef extends EnumDef + ? Iterable + : InnerDef extends StructDef + ? Iterable + : InnerDef extends ObjectPointerDef + ? (T | null)[] + : never + : never +type HasLengthOfField = Fields extends readonly [ + infer First, + ...infer Rest extends readonly StructField[], +] + ? First extends readonly [ + string, + any, + { + lengthOf: FieldName + }, + ] + ? true + : HasLengthOfField + : false +type FieldDefOutputType< + Def, + Options = undefined, + FieldName = never, + AllFields extends readonly StructField[] = [], +> = Options extends { + unpackTransform: (value: any) => infer T +} + ? T + : Def extends PrimitiveType + ? PrimitiveToTSType + : Def extends "cstring" + ? string | null + : Def extends "char*" + ? HasLengthOfField extends true + ? string | null + : number + : Def extends EnumDef + ? keyof E + : Def extends StructDef + ? OutputType + : Def extends ObjectPointerDef + ? T | null + : Def extends readonly [infer InnerDef] + ? InnerDef extends PrimitiveType + ? Iterable> + : InnerDef extends EnumDef + ? Iterable + : InnerDef extends StructDef + ? Iterable + : InnerDef extends ObjectPointerDef + ? (T | null)[] + : never + : never +type IsOptional = Options extends { + optional: true +} + ? true + : Options extends { + default: any + } + ? true + : Options extends { + lengthOf: string + } + ? true + : Options extends { + condition: () => boolean + } + ? true + : false +export type StructObjectInputType = { + [F in Fields[number] as IsOptional extends false ? F[0] : never]: FieldDefInputType +} & { + [F in Fields[number] as IsOptional extends true ? F[0] : never]?: FieldDefInputType | null +} +export type StructObjectOutputType = { + [F in Fields[number] as IsOptional extends false ? F[0] : never]: FieldDefOutputType +} & { + [F in Fields[number] as IsOptional extends true ? F[0] : never]?: FieldDefOutputType< + F[1], + F[2], + F[0], + Fields + > | null +} +export type DefineStructReturnType< + Fields extends readonly StructField[], + Options extends StructDefOptions | undefined, +> = StructDef< + Simplify< + Options extends { + reduceValue: (value: any) => infer R + } + ? R + : StructObjectOutputType + >, + Simplify< + Options extends { + mapValue: (value: infer V) => any + } + ? V + : StructObjectInputType + > +> +export interface AllocStructOptions { + lengths?: Record +} +export interface AllocStructResult { + buffer: ArrayBuffer + view: DataView + subBuffers?: Record +} +export interface EnumDef> { + __type: "enum" + type: Exclude + to(value: keyof T): number + from(value: number | bigint): keyof T + enum: T +} +type ValidationFunction = ( + value: any, + fieldName: string, + options: { + hints?: any + input?: any + }, +) => void | never +interface StructFieldOptions { + optional?: boolean + mapOptionalInline?: boolean + unpackTransform?: (value: any) => any + packTransform?: (value: any) => any + lengthOf?: string + asPointer?: boolean + default?: any + condition?: () => boolean + validate?: ValidationFunction | ValidationFunction[] +} +type StructField = + | readonly [string, PrimitiveType, StructFieldOptions?] + | readonly [string, EnumDef, StructFieldOptions?] + | readonly [string, StructDef, StructFieldOptions?] + | readonly [string, "cstring" | "char*", StructFieldOptions?] + | readonly [string, ObjectPointerDef, StructFieldOptions?] + | readonly [ + string, + readonly [EnumDef | StructDef | PrimitiveType | ObjectPointerDef], + StructFieldOptions?, + ] +export interface StructFieldPackOptions { + validationHints?: any +} +export interface StructFieldDescription { + name: string + offset: number + size: number + align: number + optional: boolean + type: PrimitiveType | EnumDef | StructDef | "cstring" | "char*" | ObjectPointerDef | readonly [any] + lengthOf?: string +} +export interface ArrayFieldMetadata { + elementSize: number + arrayOffset: number + lengthOffset: number + lengthPack: (view: DataView, offset: number, value: number) => void +} +export interface StructDef { + __type: "struct" + size: number + align: number + hasMapValue: boolean + layoutByName: Map + arrayFields: Map + pack(obj: Simplify, options?: StructFieldPackOptions): ArrayBuffer + packInto(obj: Simplify, view: DataView, offset: number, options?: StructFieldPackOptions): void + packList(objects: Simplify[], options?: StructFieldPackOptions): ArrayBuffer + unpack(buf: ArrayBuffer | SharedArrayBuffer): Simplify + unpackList(buf: ArrayBuffer | SharedArrayBuffer, count: number): Simplify[] + describe(): StructFieldDescription[] +} +export interface StructDefOptions { + default?: Record + mapValue?: (value: any) => any + reduceValue?: (value: any) => any +} +export {} diff --git a/packages/core/src/compat/nodejs/ffi.ts b/packages/core/src/compat/nodejs/ffi.ts new file mode 100644 index 000000000..9a7aa0f8f --- /dev/null +++ b/packages/core/src/compat/nodejs/ffi.ts @@ -0,0 +1,276 @@ +import koffi from "koffi" +import { fileURLToPath } from "node:url" +import { isAnyArrayBuffer, isArrayBuffer, isArrayBufferView } from "node:util/types" +import type { + ConvertFns, + DlopenFunction, + FFIFunction, + FFITypeOrString, + JSCallback as IJSCallback, + Pointer, + PtrFunction, + ToArrayBufferFunction, +} from "../ffi.js" +import { FFIType } from "../FFIType.js" +import { unsafePointerOf, unsafeArrayBufferAt } from "unsafe-pointer" + +export { FFIType } + +const FFITypeStringToType = { + ["char"]: FFIType.char, + ["int8_t"]: FFIType.int8_t, + ["i8"]: FFIType.i8, + ["uint8_t"]: FFIType.uint8_t, + ["u8"]: FFIType.u8, + ["int16_t"]: FFIType.int16_t, + ["i16"]: FFIType.i16, + ["uint16_t"]: FFIType.uint16_t, + ["u16"]: FFIType.u16, + ["int32_t"]: FFIType.int32_t, + ["i32"]: FFIType.i32, + ["int"]: FFIType.int, + ["uint32_t"]: FFIType.uint32_t, + ["u32"]: FFIType.u32, + ["int64_t"]: FFIType.int64_t, + ["i64"]: FFIType.i64, + ["uint64_t"]: FFIType.uint64_t, + ["u64"]: FFIType.u64, + ["double"]: FFIType.double, + ["f64"]: FFIType.f64, + ["float"]: FFIType.float, + ["f32"]: FFIType.f32, + ["bool"]: FFIType.bool, + ["ptr"]: FFIType.ptr, + ["pointer"]: FFIType.pointer, + ["void"]: FFIType.void, + ["cstring"]: FFIType.cstring, + ["function"]: FFIType.pointer, // for now + ["usize"]: FFIType.uint64_t, // for now + ["callback"]: FFIType.pointer, // for now + ["napi_env"]: FFIType.napi_env, + ["napi_value"]: FFIType.napi_value, + ["buffer"]: FFIType.buffer, +} as const + +const BunPtrType = koffi.pointer("BunPtr", koffi.opaque()) +const NapiEnvType = koffi.opaque("NapiEnv") +const NapiValueType = koffi.opaque("NapiValue") +const BufferType = koffi.opaque("Buffer") + +const ffiTypeToKoffiTypeMap: Record = { + [FFIType.char]: koffi.types.char, + [FFIType.int8_t]: koffi.types.int8_t, + [FFIType.uint8_t]: koffi.types.uint8_t, + [FFIType.int16_t]: koffi.types.int16_t, + [FFIType.uint16_t]: koffi.types.uint16_t, + [FFIType.int32_t]: koffi.types.int32_t, + [FFIType.uint32_t]: koffi.types.uint32_t, + [FFIType.int64_t]: koffi.types.int64_t, + [FFIType.uint64_t]: koffi.types.uint64_t, + [FFIType.double]: koffi.types.double, + [FFIType.float]: koffi.types.float, + [FFIType.bool]: koffi.types.bool, + [FFIType.ptr]: BunPtrType, + [FFIType.void]: koffi.types.void, + [FFIType.cstring]: koffi.types.string, + [FFIType.i64_fast]: koffi.types.int64_t, + [FFIType.u64_fast]: koffi.types.uint64_t, + [FFIType.function]: BunPtrType, + [FFIType.napi_env]: NapiEnvType, + [FFIType.napi_value]: NapiValueType, + [FFIType.buffer]: BufferType, +} + +function ffiTypeToKoffiType(type: FFITypeOrString): koffi.TypeSpec { + let numberType: FFIType + if (typeof type === "number") { + numberType = type + } else { + numberType = FFITypeStringToType[type] + } + + if (numberType === FFIType.napi_env || numberType === FFIType.napi_value || numberType === FFIType.cstring) { + throw new Error(`Unsupported FFI type: ${FFIType[numberType]} (${type})`) + } + + return ffiTypeToKoffiTypeMap[numberType] +} + +type KoffiExternal = object & { __koffi_external__: true } + +function koffiPointerToNumber(pointer: KoffiExternal | bigint | number | null): number { + if (pointer === null) { + return 0 + } else if (typeof pointer === "object") { + return Number(koffi.address(pointer)) + } else if (typeof pointer === "bigint") { + return Number(pointer) + } else { + return pointer + } +} + +export class JSCallback implements IJSCallback { + #threadsafe: boolean + #registeredCallback: koffi.IKoffiRegisteredCallback | null + + constructor(callback: (...args: any[]) => any, definition: FFIFunction) { + // Wrap callback to convert koffi External pointer args → numbers (Bun convention), + // mirroring the conversion done for FFI function return values. + const ptrArgIndices: number[] = [] + if (definition.args) { + for (let i = 0; i < definition.args.length; i++) { + if (isPointerType(definition.args[i])) ptrArgIndices.push(i) + } + } + const wrappedCallback = + ptrArgIndices.length > 0 + ? (...args: any[]) => { + for (const i of ptrArgIndices) { + args[i] = koffiPointerToNumber(args[i]) + } + return callback(...args) + } + : callback + + const proto = koffi.proto(returnsToKoffiType(definition.returns), argsToKoffiTypes(definition.args)) + this.#registeredCallback = koffi.register(wrappedCallback, koffi.pointer(proto)) + this.#threadsafe = definition.threadsafe ?? false + } + + get ptr(): Pointer | null { + if (!this.#registeredCallback) { + return null + } + return Number(koffi.address(this.#registeredCallback)) as Pointer + } + + get threadsafe(): boolean { + return this.#threadsafe + } + + close() { + if (!this.#registeredCallback) { + return + } + koffi.unregister(this.#registeredCallback) + this.#registeredCallback = null + } +} + +function argsToKoffiTypes(args: readonly FFITypeOrString[] | undefined): koffi.TypeSpec[] { + return args?.map(ffiTypeToKoffiType) ?? [] +} + +function returnsToKoffiType(returns: FFITypeOrString | undefined): koffi.TypeSpec { + return ffiTypeToKoffiType(returns ?? FFIType.void) +} + +function isPointerType(type: FFITypeOrString | undefined): boolean { + if (type === undefined) return false + const num = typeof type === "number" ? type : FFITypeStringToType[type as keyof typeof FFITypeStringToType] + return num === FFIType.ptr +} + +function isBigIntType(type: FFITypeOrString | undefined): boolean { + if (type === undefined) return false + const num = typeof type === "number" ? type : FFITypeStringToType[type as keyof typeof FFITypeStringToType] + return num === FFIType.i64 || num === FFIType.u64 || num === FFIType.i64_fast || num === FFIType.u64_fast +} + +// koffi passes null for 0-length TypedArrays, but Bun passes a valid non-null +// address. Native code may treat null as "no data" even when length is also +// passed as 0. Use a static 1-byte sentinel so the pointer is always non-null. +const emptyPtrSentinel = new Uint8Array(1) + +function pointerArgToKoffiPointerArg(arg: unknown): unknown { + if (typeof arg === "number") { + // Real native address (e.g. from JSCallback.ptr or read from output buffer) — + // koffi accepts BigInt for pointer params. + return BigInt(arg) + } + + if ((isArrayBufferView(arg) || isArrayBuffer(arg)) && arg.byteLength === 0) { + return emptyPtrSentinel + } + + return arg +} + +function ffiFunctionToKoffiFunction unknown>( + lib: koffi.IKoffiLib, + name: string, + type: FFIFunction, +): T & koffi.KoffiFunction { + const func = lib.func(name, returnsToKoffiType(type.returns), argsToKoffiTypes(type.args)) + + const ptrArgIndices: number[] = [] + if (type.args) { + for (let i = 0; i < type.args.length; i++) { + if (isPointerType(type.args[i])) ptrArgIndices.push(i) + } + } + const returnsPtr = isPointerType(type.returns) + // koffi may return small u64/i64 values as number instead of bigint; + // Bun always returns bigint for these types. + const returnsBigInt = isBigIntType(type.returns) + + if (ptrArgIndices.length === 0 && !returnsPtr && !returnsBigInt) { + return func as T & koffi.KoffiFunction + } + + const wrapper = (...args: unknown[]) => { + for (const i of ptrArgIndices) { + args[i] = pointerArgToKoffiPointerArg(args[i]) + } + + const result = func(...args) + + if (returnsPtr) { + return koffiPointerToNumber(result) + } + + if (returnsBigInt) { + return BigInt(result) + } + + return result + } + Object.defineProperty(wrapper, "name", { value: name }) + return wrapper as T & koffi.KoffiFunction +} + +/** + * Get Pointer address of a TypedArray or ArrayBuffer + */ +export const ptr: PtrFunction = (value) => unsafePointerOf(value as ArrayBuffer) + +/** + * Get an ArrayBuffer aliasing the memory at the given Pointer number + */ +export const toArrayBuffer: ToArrayBufferFunction = unsafeArrayBufferAt + +export const suffix: string = koffi.extension.slice(1) + +export const dlopen: DlopenFunction = (name, symbols) => { + let loadPath: string + if (typeof name === "string") { + loadPath = name + } else if (name instanceof URL) { + loadPath = fileURLToPath(name) + } else { + throw new Error(`Unsupported FFI library name: ${name}`) + } + const lib = koffi.load(loadPath) + const library: Record = {} + for (const [name, ffiFunction] of Object.entries(symbols)) { + // Idea: could use defineProperty to lazily create the koffi.func + library[name] = ffiFunctionToKoffiFunction(lib, name, ffiFunction) + } + return { + symbols: library as unknown as ConvertFns, + close: () => lib.unload(), + } +} + +export const __url = import.meta.url diff --git a/packages/core/src/compat/nodejs/registerBun.ts b/packages/core/src/compat/nodejs/registerBun.ts new file mode 100644 index 000000000..a9857f516 --- /dev/null +++ b/packages/core/src/compat/nodejs/registerBun.ts @@ -0,0 +1,84 @@ +import * as mod from "node:module" +import { extname } from "node:path" +import { __url as ffiUrl } from "../ffi.js" +import * as NodeBun from "../runtime.js" +import { fileURLToPath } from "node:url" + +if (typeof globalThis.Bun === "undefined") { + Object.defineProperty(globalThis, "Bun", { + value: NodeBun, + writable: false, + enumerable: true, + configurable: true, + }) +} + +const recentOddSpecifiers = new Map() +function popRecentSpecifier(specifier: string) { + const context = recentOddSpecifiers.get(specifier) + if (context) { + recentOddSpecifiers.delete(specifier) + } else if (recentOddSpecifiers.size > 10) { + const key = recentOddSpecifiers.keys().next().value + if (key) { + recentOddSpecifiers.delete(key) + } + } + return context +} + +function extendError(error: unknown, specifier: string, context: mod.ResolveHookContext | undefined) { + if (error && typeof error === "object" && "message" in error) { + error.message += `\nSpecifier: '${specifier}'\nFrom: ${JSON.stringify(context, null, 2)}` + } + return error +} + +const NORMAL_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"]) + +mod.registerHooks({ + resolve: (specifier, context, next) => { + try { + if (specifier === "bun:ffi") { + return next(ffiUrl, context) + } + + if (specifier.startsWith("bun:")) { + throw new Error(`Untransformed Bun specifier: '${specifier}' from '${context.parentURL}'`) + } + + const result = next(specifier, context) + + if ( + (!result.url.startsWith("node:") && !NORMAL_EXTENSIONS.has(extname(result.url))) || + (context.importAttributes.type && + context.importAttributes.type !== "module" && + context.importAttributes.type !== "commonjs") + ) { + recentOddSpecifiers.set(result.url, context) + } + + return result + } catch (error) { + throw extendError(error, specifier, context) + } + }, + load: (specifier, context, next) => { + // Exists only for error reporting / debugging. + const resolveContext = popRecentSpecifier(specifier) + try { + return next(specifier, context) + } catch (error) { + if (context.importAttributes.type === "file" || context.importAttributes.type?.includes("/")) { + const absolutePath = fileURLToPath(specifier) + return { + format: "module", + source: `export default ${JSON.stringify(absolutePath)}`, + shortCircuit: true, + } + } + + throw extendError(error, specifier, resolveContext) + } + }, +}) diff --git a/packages/core/src/compat/nodejs/registerResolveJs.ts b/packages/core/src/compat/nodejs/registerResolveJs.ts new file mode 100644 index 000000000..a8f28cab6 --- /dev/null +++ b/packages/core/src/compat/nodejs/registerResolveJs.ts @@ -0,0 +1,36 @@ +import * as mod from "node:module" +import path from "node:path" + +// allow import(foo.js) to resolve to import(foo.ts) +// required for workers under vitest +const extensionMap: Record = { + ".js": ".ts", + ".jsx": ".tsx", + ".cjs": ".cts", + ".mjs": ".mts", +} +mod.registerHooks({ + resolve: (specifier, context, next) => { + try { + return next(specifier, context) + } catch (error) { + if (!error || typeof error !== "object" || !("code" in error)) { + throw error + } + + if (error.code === "ERR_MODULE_NOT_FOUND") { + const extension = path.extname(specifier) + const newExtension = extension in extensionMap ? extensionMap[extension] : undefined + if (newExtension) { + return next(specifier.slice(0, -extension.length) + newExtension, context) + } + } + + if (error.code === "ERR_UNSUPPORTED_ESM_URL_SCHEME" && "message" in error) { + error.message += `\nSpecifier: '${specifier}'\nContext: '${JSON.stringify(context)}'` + } + + throw error + } + }, +}) diff --git a/packages/core/src/compat/nodejs/runtime.ts b/packages/core/src/compat/nodejs/runtime.ts new file mode 100644 index 000000000..633365106 --- /dev/null +++ b/packages/core/src/compat/nodejs/runtime.ts @@ -0,0 +1,37 @@ +import { mkdir, writeFile as writeFileNode } from "node:fs/promises" +import { dirname } from "node:path" +import { fileURLToPath } from "node:url" + +import stringWidthLib from "string-width" +import stripAnsiLib from "strip-ansi" + +import type { WriteFileOptions } from "../runtime.js" + +export function sleep(msOrDate: number | Date): Promise { + const ms = msOrDate instanceof Date ? msOrDate.getTime() - Date.now() : msOrDate + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export const stringWidth = stringWidthLib +export const stripANSI = stripAnsiLib + +export async function writeFile( + destination: string | URL, + data: string | ArrayBufferView, + options?: WriteFileOptions, +): Promise { + const destinationPath = destination instanceof URL ? fileURLToPath(destination) : destination + + if (options?.createPath) { + await mkdir(dirname(destinationPath), { recursive: true }) + } + + if (typeof data === "string") { + await writeFileNode(destination, data, { mode: options?.mode, encoding: "utf8" }) + return new TextEncoder().encode(data).length + } + + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + await writeFileNode(destination, bytes, { mode: options?.mode }) + return bytes.length +} diff --git a/packages/core/src/compat/nodejs/test.ts b/packages/core/src/compat/nodejs/test.ts new file mode 100644 index 000000000..9429802cf --- /dev/null +++ b/packages/core/src/compat/nodejs/test.ts @@ -0,0 +1,22 @@ +import type { mock as bunMock, spyOn as bunSpyOn } from "bun:test" +import { vi, expect } from "vitest" +export * from "vitest" + +export const mock: typeof bunMock = vi.fn as any +export const spyOn: typeof bunSpyOn = vi.spyOn as any + +// Bun's toInclude → vitest's toContain +expect.extend({ + toInclude(received: unknown, expected: unknown) { + const pass = + typeof received === "string" + ? received.includes(expected as string) + : Array.isArray(received) + ? received.includes(expected) + : false + return { + pass, + message: () => `expected ${this.utils.printReceived(received)} to include ${this.utils.printExpected(expected)}`, + } + }, +}) diff --git a/packages/core/src/compat/nodejs/trampoline.worker.ts b/packages/core/src/compat/nodejs/trampoline.worker.ts new file mode 100644 index 000000000..c284409b5 --- /dev/null +++ b/packages/core/src/compat/nodejs/trampoline.worker.ts @@ -0,0 +1,29 @@ +import { parentPort as maybeParentPort, workerData } from "node:worker_threads" + +function setup() { + if (!maybeParentPort) { + throw new Error("Expected parentPort in worker thread") + } + const parentPort = maybeParentPort + globalThis.postMessage = (message) => parentPort.postMessage(message) + + let onmessage: ((event: MessageEvent) => void) | null = null + Object.defineProperty(globalThis, "onmessage", { + configurable: true, + enumerable: true, + set(handler) { + if (onmessage) { + parentPort.removeListener("message", onmessage) + onmessage = null + } + + if (handler) { + onmessage = (data) => handler({ data }) + parentPort.on("message", onmessage) + } + }, + }) +} + +setup() +await import(workerData.targetUrl) diff --git a/packages/core/src/compat/runtime.ts b/packages/core/src/compat/runtime.ts new file mode 100644 index 000000000..c524c7a23 --- /dev/null +++ b/packages/core/src/compat/runtime.ts @@ -0,0 +1,25 @@ +export interface WriteFileOptions { + createPath?: boolean + mode?: number +} + +type RuntimeModule = { + sleep: (msOrDate: number | Date) => Promise + stringWidth: (text: string) => number + stripANSI: (text: string) => string + writeFile: (destination: string | URL, data: string | ArrayBufferView, options?: WriteFileOptions) => Promise +} + +const runtime: RuntimeModule = process.versions.bun + ? { + sleep: Bun.sleep, + stringWidth: Bun.stringWidth, + stripANSI: Bun.stripANSI, + writeFile: Bun.write as RuntimeModule["writeFile"], + } + : await import("./nodejs/runtime.js") + +export const sleep = runtime.sleep +export const stringWidth = runtime.stringWidth +export const stripANSI = runtime.stripANSI +export const writeFile = runtime.writeFile diff --git a/packages/core/src/compat/test.ts b/packages/core/src/compat/test.ts new file mode 100644 index 000000000..54cec7971 --- /dev/null +++ b/packages/core/src/compat/test.ts @@ -0,0 +1 @@ +export * from "./nodejs/test.js" diff --git a/packages/core/src/compat/testHelpers.ts b/packages/core/src/compat/testHelpers.ts new file mode 100644 index 000000000..4694327bb --- /dev/null +++ b/packages/core/src/compat/testHelpers.ts @@ -0,0 +1,47 @@ +import * as cp from "node:child_process" + +export interface SpawnSyncOptions { + cwd?: string + env?: NodeJS.ProcessEnv + stderr?: "ignore" | "pipe" + stdin?: "ignore" | "pipe" + stdout?: "ignore" | "pipe" + timeout?: number + maxBuffer?: number +} + +export interface SpawnSyncResult { + stdout: Uint8Array + stderr: Uint8Array + exitCode: number + success: boolean + pid: number + signalCode?: NodeJS.Signals | number +} + +export function spawnSync(cmd: string[], options: SpawnSyncOptions = {}): SpawnSyncResult { + const [file, ...rawArgs] = cmd + const shouldAddNodeTypeFlags = !process.versions.bun && (file === process.execPath || file === "node") + const args = shouldAddNodeTypeFlags ? ["--experimental-transform-types", ...rawArgs] : rawArgs + + const result = cp.spawnSync(file, args, { + cwd: options.cwd, + env: options.env, + stdio: [ + options.stdin === "pipe" ? "pipe" : "ignore", + options.stdout === "pipe" ? "pipe" : "ignore", + options.stderr === "pipe" ? "pipe" : "ignore", + ], + timeout: options.timeout, + maxBuffer: options.maxBuffer, + }) + + return { + stdout: result.stdout ?? Buffer.alloc(0), + stderr: result.stderr ?? Buffer.alloc(0), + exitCode: result.status ?? 1, + success: result.status === 0, + pid: result.pid ?? 0, + signalCode: result.signal ?? undefined, + } +} diff --git a/packages/core/src/edit-buffer.test.ts b/packages/core/src/edit-buffer.test.ts index da35d2439..0a10354d3 100644 --- a/packages/core/src/edit-buffer.test.ts +++ b/packages/core/src/edit-buffer.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { sleep } from "./compat/runtime.js" import { EditBuffer } from "./edit-buffer.js" describe("EditBuffer", () => { @@ -742,6 +743,11 @@ describe("EditBuffer Placeholder", () => { afterEach(() => { buffer.destroy() }) + + if (!process.versions.bun) { + // nodejs vitest fails unless there's at least one test in a describe block + it.todo("placeholder tests", () => {}) + } }) describe("EditBuffer Events", () => { @@ -905,19 +911,19 @@ describe("EditBuffer Events", () => { }) testBuffer1.setText("Buffer 1") - await Bun.sleep(10) + await sleep(10) const count1AfterSetText = count1 testBuffer1.moveCursorRight() - await Bun.sleep(10) + await sleep(10) expect(count1).toBeGreaterThan(count1AfterSetText) expect(count2).toBe(0) testBuffer2.setText("Buffer 2") - await Bun.sleep(10) + await sleep(10) const count2AfterSetText = count2 testBuffer2.moveCursorRight() - await Bun.sleep(10) + await sleep(10) expect(count1).toBe(count1AfterSetText + 1) expect(count2).toBeGreaterThan(count2AfterSetText) @@ -958,7 +964,7 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello World") - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(0) testBuffer.destroy() @@ -973,11 +979,11 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount testBuffer.insertText(" World") - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) testBuffer.destroy() @@ -992,12 +998,12 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello World") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount testBuffer.setCursorToLineCol(0, 5) testBuffer.deleteChar() - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) testBuffer.destroy() @@ -1012,12 +1018,12 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount testBuffer.setCursorToLineCol(0, 5) testBuffer.deleteCharBackward() - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) testBuffer.destroy() @@ -1032,12 +1038,12 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Line 1\nLine 2\nLine 3") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount testBuffer.gotoLine(1) testBuffer.deleteLine() - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) testBuffer.destroy() @@ -1052,12 +1058,12 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount testBuffer.setCursorToLineCol(0, 5) testBuffer.newLine() - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) testBuffer.destroy() @@ -1077,7 +1083,7 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) expect(count1).toBeGreaterThan(0) expect(count2).toBeGreaterThan(0) @@ -1089,7 +1095,7 @@ describe("EditBuffer Events", () => { it("should support removing content-changed listeners", async () => { const testBuffer = EditBuffer.create("wcwidth") testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) let eventCount = 0 const listener = () => { @@ -1098,13 +1104,13 @@ describe("EditBuffer Events", () => { testBuffer.on("content-changed", listener) testBuffer.insertText(" World") - await Bun.sleep(10) + await sleep(10) const firstCount = eventCount testBuffer.off("content-changed", listener) testBuffer.insertText("!") - await Bun.sleep(10) + await sleep(10) // Count should not have increased after removing listener expect(eventCount).toBe(firstCount) @@ -1127,21 +1133,21 @@ describe("EditBuffer Events", () => { }) testBuffer1.setText("Buffer 1") - await Bun.sleep(10) + await sleep(10) const count1AfterSetText = count1 testBuffer1.insertText(" updated") - await Bun.sleep(10) + await sleep(10) expect(count1).toBeGreaterThan(count1AfterSetText) expect(count2).toBe(0) testBuffer2.setText("Buffer 2") - await Bun.sleep(10) + await sleep(10) const count2AfterSetText = count2 testBuffer2.insertText(" updated") - await Bun.sleep(10) + await sleep(10) expect(count1).toBe(count1AfterSetText + 1) expect(count2).toBeGreaterThan(count2AfterSetText) @@ -1159,7 +1165,7 @@ describe("EditBuffer Events", () => { }) testBuffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) const countBeforeDestroy = eventCount @@ -1399,7 +1405,7 @@ describe("EditBuffer History Management", () => { }) buffer.setText("Hello") - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(0) }) @@ -1411,7 +1417,7 @@ describe("EditBuffer History Management", () => { }) buffer.replaceText("Hello") - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(0) }) @@ -1423,7 +1429,7 @@ describe("EditBuffer History Management", () => { }) buffer.setTextOwned("Hello") - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(0) }) @@ -1551,11 +1557,11 @@ describe("EditBuffer Clear Method", () => { }) buffer.setText("Hello World") - await Bun.sleep(10) + await sleep(10) const countAfterSetText = eventCount buffer.clear() - await Bun.sleep(10) + await sleep(10) expect(eventCount).toBeGreaterThan(countAfterSetText) }) @@ -1568,11 +1574,11 @@ describe("EditBuffer Clear Method", () => { buffer.setText("Hello World") buffer.setCursorToLineCol(0, 5) - await Bun.sleep(10) + await sleep(10) const countBeforeClear = eventCount buffer.clear() - await Bun.sleep(10) + await sleep(10) // Should emit cursor-changed when resetting cursor to 0,0 expect(eventCount).toBeGreaterThan(countBeforeClear) @@ -1591,13 +1597,13 @@ describe("EditBuffer Clear Method", () => { buffer.setText("Hello World") buffer.setCursorToLineCol(0, 5) - await Bun.sleep(10) + await sleep(10) const contentCountBefore = contentChangedCount const cursorCountBefore = cursorChangedCount buffer.clear() - await Bun.sleep(10) + await sleep(10) expect(contentChangedCount).toBeGreaterThan(contentCountBefore) expect(cursorChangedCount).toBeGreaterThan(cursorCountBefore) diff --git a/packages/core/src/edit-buffer.ts b/packages/core/src/edit-buffer.ts index 5be36e0a3..f07e945fc 100644 --- a/packages/core/src/edit-buffer.ts +++ b/packages/core/src/edit-buffer.ts @@ -1,5 +1,5 @@ import { resolveRenderLib, type LogicalCursor, type RenderLib } from "./zig.js" -import { type Pointer } from "bun:ffi" +import { type Pointer } from "./compat/ffi.js" import { type WidthMethod, type Highlight } from "./types.js" import { RGBA } from "./lib/RGBA.js" import { EventEmitter } from "events" diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 4ee917d0e..36c50d352 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -1,6 +1,6 @@ import { RGBA } from "./lib/RGBA.js" import { resolveRenderLib, type RenderLib, type VisualCursor, type LineInfo } from "./zig.js" -import { type Pointer } from "bun:ffi" +import { type Pointer } from "./compat/ffi.js" import type { EditBuffer } from "./edit-buffer.js" import { createExtmarksController } from "./lib/index.js" diff --git a/packages/core/src/lib/RGBA.ts b/packages/core/src/lib/RGBA.ts index 1dae109a9..b13e911e7 100644 --- a/packages/core/src/lib/RGBA.ts +++ b/packages/core/src/lib/RGBA.ts @@ -1,4 +1,5 @@ export class RGBA { + static readonly BYTE_LENGTH = 16 // 4 floats * 4 bytes per float buffer: Float32Array constructor(buffer: Float32Array) { diff --git a/packages/core/src/lib/clipboard.ts b/packages/core/src/lib/clipboard.ts index 3a04d9ac3..f4b0c4f45 100644 --- a/packages/core/src/lib/clipboard.ts +++ b/packages/core/src/lib/clipboard.ts @@ -1,7 +1,7 @@ // OSC 52 clipboard support for terminal applications. // Delegates to native Zig implementation for ANSI sequence generation. -import type { Pointer } from "bun:ffi" +import type { Pointer } from "../compat/ffi.js" import type { RenderLib } from "../zig.js" export enum ClipboardTarget { diff --git a/packages/core/src/lib/extmarks-multiwidth.test.ts b/packages/core/src/lib/extmarks-multiwidth.test.ts index f00e52606..069465fec 100644 --- a/packages/core/src/lib/extmarks-multiwidth.test.ts +++ b/packages/core/src/lib/extmarks-multiwidth.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, afterEach } from "bun:test" +import { stringWidth } from "../compat/runtime.js" import { TextareaRenderable } from "../renderables/Textarea.js" import { createTestRenderer, type TestRenderer, type MockInput } from "../testing/test-renderer.js" import { type ExtmarksController } from "./extmarks.js" @@ -64,12 +65,12 @@ describe("ExtmarksController - Multi-width Graphemes", () => { if (text[i] === "\n") { displayOffset += 1 } else { - displayOffset += Bun.stringWidth(text[i]) + displayOffset += stringWidth(text[i]) } } const mentionText = "@git-committer" - const mentionDisplayWidth = Bun.stringWidth(mentionText) + const mentionDisplayWidth = stringWidth(mentionText) const mentionStart = displayOffset // Should be 11 const mentionEnd = displayOffset + mentionDisplayWidth // Should be 25 diff --git a/packages/core/src/lib/extmarks.ts b/packages/core/src/lib/extmarks.ts index fc77247bf..a76705738 100644 --- a/packages/core/src/lib/extmarks.ts +++ b/packages/core/src/lib/extmarks.ts @@ -1,5 +1,6 @@ import type { EditBuffer } from "../edit-buffer.js" import type { EditorView } from "../editor-view.js" +import { stringWidth } from "../compat/runtime.js" import { ExtmarksHistory, type ExtmarksSnapshot } from "./extmarks-history.js" export interface Extmark { @@ -614,7 +615,7 @@ export class ExtmarksController { j++ } const chunk = text.substring(i, j) - const chunkWidth = Bun.stringWidth(chunk) + const chunkWidth = stringWidth(chunk) if (displayWidthSoFar + chunkWidth < offset) { // Entire chunk fits before offset @@ -624,7 +625,7 @@ export class ExtmarksController { // Offset is within this chunk - need to find exact position // Walk character by character for (let k = i; k < j && displayWidthSoFar < offset; k++) { - const charWidth = Bun.stringWidth(text[k]) + const charWidth = stringWidth(text[k]) displayWidthSoFar += charWidth } break diff --git a/packages/core/src/lib/parse.mouse.test.ts b/packages/core/src/lib/parse.mouse.test.ts index 4bc42779a..7e99fc383 100644 --- a/packages/core/src/lib/parse.mouse.test.ts +++ b/packages/core/src/lib/parse.mouse.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach } from "bun:test" -import { MouseParser, type RawMouseEvent } from "./parse.mouse" +import { MouseParser, type RawMouseEvent } from "./parse.mouse.js" // Encode a basic/X10 mouse event: ESC [ M Cb Cx Cy // buttonByte is the logical value (before the +32 wire offset), x/y are 0-based. diff --git a/packages/core/src/lib/paste.ts b/packages/core/src/lib/paste.ts index 93e704801..e4c1da706 100644 --- a/packages/core/src/lib/paste.ts +++ b/packages/core/src/lib/paste.ts @@ -1,3 +1,5 @@ +import { stripANSI } from "../compat/runtime.js" + export type PasteKind = "text" | "binary" | "unknown" export interface PasteMetadata { @@ -12,5 +14,5 @@ export function decodePasteBytes(bytes: Uint8Array): string { } export function stripAnsiSequences(text: string): string { - return Bun.stripANSI(text) + return stripANSI(text) } diff --git a/packages/core/src/lib/selection.ts b/packages/core/src/lib/selection.ts index fe42d0f46..a2428ec7b 100644 --- a/packages/core/src/lib/selection.ts +++ b/packages/core/src/lib/selection.ts @@ -1,4 +1,5 @@ -import { Renderable, type ViewportBounds } from "../index.js" +import { Renderable } from "../Renderable.js" +import type { ViewportBounds } from "../types.js" import { coordinateToCharacterIndex, fonts } from "./ascii.font.js" class SelectionAnchor { diff --git a/packages/core/src/lib/tree-sitter/assets/update.ts b/packages/core/src/lib/tree-sitter/assets/update.ts index d877e0780..4a210c358 100644 --- a/packages/core/src/lib/tree-sitter/assets/update.ts +++ b/packages/core/src/lib/tree-sitter/assets/update.ts @@ -146,15 +146,17 @@ async function downloadAndCombineQueries( } async function generateDefaultParsersFile(parsers: GeneratedParser[], outputPath: string): Promise { - const imports = parsers + const constants = parsers .map((parser) => { const safeFiletype = parser.filetype.replace(/[^a-zA-Z0-9]/g, "_") const lines = [ - `import ${safeFiletype}_highlights from "${parser.highlightsPath}" with { type: "file" }`, - `import ${safeFiletype}_language from "${parser.languagePath}" with { type: "file" }`, + `const ${safeFiletype}_highlights = fileURLToPath(new URL("${parser.highlightsPath}", import.meta.url))`, + `const ${safeFiletype}_language = fileURLToPath(new URL("${parser.languagePath}", import.meta.url))`, ] if (parser.injectionsPath) { - lines.push(`import ${safeFiletype}_injections from "${parser.injectionsPath}" with { type: "file" }`) + lines.push( + `const ${safeFiletype}_injections = fileURLToPath(new URL("${parser.injectionsPath}", import.meta.url))`, + ) } return lines.join("\n") }) @@ -163,13 +165,9 @@ async function generateDefaultParsersFile(parsers: GeneratedParser[], outputPath const parserDefinitions = parsers .map((parser) => { const safeFiletype = parser.filetype.replace(/[^a-zA-Z0-9]/g, "_") - const queriesLines = [ - ` highlights: [resolve(dirname(fileURLToPath(import.meta.url)), ${safeFiletype}_highlights)],`, - ] + const queriesLines = [` highlights: [${safeFiletype}_highlights],`] if (parser.injectionsPath) { - queriesLines.push( - ` injections: [resolve(dirname(fileURLToPath(import.meta.url)), ${safeFiletype}_injections)],`, - ) + queriesLines.push(` injections: [${safeFiletype}_injections],`) } const injectionMappingLine = parser.injectionMapping @@ -182,7 +180,7 @@ async function generateDefaultParsersFile(parsers: GeneratedParser[], outputPath ${aliasesLine ? aliasesLine + "\n" : ""} queries: { ${queriesLines.join("\n")} }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), ${safeFiletype}_language),${injectionMappingLine ? "\n" + injectionMappingLine : ""} + wasm: ${safeFiletype}_language,${injectionMappingLine ? "\n" + injectionMappingLine : ""} }` }) .join(",\n") @@ -191,11 +189,10 @@ ${queriesLines.join("\n")} // Run 'bun assets/update.ts' to regenerate this file // Last generated: ${new Date().toISOString()} -import type { FiletypeParserOptions } from "./types" -import { resolve, dirname } from "path" -import { fileURLToPath } from "url" +import { fileURLToPath } from "node:url" +import type { FiletypeParserOptions } from "./types.js" -${imports} +${constants} // Cached parsers to avoid re-resolving paths on every call let _cachedParsers: FiletypeParserOptions[] | undefined diff --git a/packages/core/src/lib/tree-sitter/cache.test.ts b/packages/core/src/lib/tree-sitter/cache.test.ts index cea0a4014..d70fb1caa 100644 --- a/packages/core/src/lib/tree-sitter/cache.test.ts +++ b/packages/core/src/lib/tree-sitter/cache.test.ts @@ -1,32 +1,40 @@ -import { test, expect, beforeEach, beforeAll, afterAll, describe } from "bun:test" -import { TreeSitterClient, addDefaultParsers } from "./client.js" +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { createServer, type Server } from "node:http" +import { mkdir, readdir, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { mkdir, readdir, stat, writeFile } from "node:fs/promises" -import { readFileSync } from "node:fs" +import { TreeSitterClient } from "./client.js" import type { FiletypeParserOptions } from "./types.js" describe("TreeSitterClient Caching", () => { let dataPath: string - let testServer: any + let testServer: Server const TEST_PORT = 55231 const BASE_URL = `http://localhost:${TEST_PORT}` beforeAll(async () => { - const assetsDir = resolve(__dirname, "assets") - testServer = Bun.serve({ - port: TEST_PORT, - fetch(req) { - const url = new URL(req.url) - const filePath = join(assetsDir, url.pathname) - return new Response(readFileSync(filePath)) - }, + const assetsDir = resolve(import.meta.dirname, "assets") + testServer = createServer((req, res) => { + const filePath = join(assetsDir, req.url ?? "/") + try { + const data = readFileSync(filePath) + res.writeHead(200) + res.end(data) + } catch { + res.writeHead(404) + res.end("Not found") + } + }) + await new Promise((resolve, reject) => { + testServer.on("error", reject) + testServer.listen(TEST_PORT, resolve) }) }) afterAll(async () => { if (testServer) { - testServer.stop() + await new Promise((resolve) => testServer.close(() => resolve())) } }) diff --git a/packages/core/src/lib/tree-sitter/client.ts b/packages/core/src/lib/tree-sitter/client.ts index c7dcd5cc4..b8c7f38ea 100644 --- a/packages/core/src/lib/tree-sitter/client.ts +++ b/packages/core/src/lib/tree-sitter/client.ts @@ -16,6 +16,7 @@ import { resolve, isAbsolute, parse } from "path" import { existsSync } from "fs" import { registerEnvVar, env } from "../env.js" import { isBunfsPath, normalizeBunfsPath } from "../bunfs.js" +import { Worker } from "../../compat/Worker.js" registerEnvVar({ name: "OTUI_TREE_SITTER_WORKER_PATH", @@ -52,7 +53,7 @@ const isUrl = (path: string) => path.startsWith("http://") || path.startsWith("h // TODO: TreeSitterClient should have a setOptions method, passing it on to the worker etc. export class TreeSitterClient extends EventEmitter { private initialized = false - private worker: Worker | undefined + private worker: InstanceType | undefined private buffers: Map = new Map() private initializePromise: Promise | undefined private initializeResolvers: diff --git a/packages/core/src/lib/tree-sitter/default-parsers.ts b/packages/core/src/lib/tree-sitter/default-parsers.ts index 5c4018cc0..310ec65ae 100644 --- a/packages/core/src/lib/tree-sitter/default-parsers.ts +++ b/packages/core/src/lib/tree-sitter/default-parsers.ts @@ -2,21 +2,20 @@ // Run 'bun assets/update.ts' to regenerate this file // Last generated: 2026-03-20T21:07:24.696Z +import { fileURLToPath } from "node:url" import type { FiletypeParserOptions } from "./types.js" -import { resolve, dirname } from "path" -import { fileURLToPath } from "url" -import javascript_highlights from "./assets/javascript/highlights.scm" with { type: "file" } -import javascript_language from "./assets/javascript/tree-sitter-javascript.wasm" with { type: "file" } -import typescript_highlights from "./assets/typescript/highlights.scm" with { type: "file" } -import typescript_language from "./assets/typescript/tree-sitter-typescript.wasm" with { type: "file" } -import markdown_highlights from "./assets/markdown/highlights.scm" with { type: "file" } -import markdown_language from "./assets/markdown/tree-sitter-markdown.wasm" with { type: "file" } -import markdown_injections from "./assets/markdown/injections.scm" with { type: "file" } -import markdown_inline_highlights from "./assets/markdown_inline/highlights.scm" with { type: "file" } -import markdown_inline_language from "./assets/markdown_inline/tree-sitter-markdown_inline.wasm" with { type: "file" } -import zig_highlights from "./assets/zig/highlights.scm" with { type: "file" } -import zig_language from "./assets/zig/tree-sitter-zig.wasm" with { type: "file" } +const javascript_highlights = fileURLToPath(new URL("./assets/javascript/highlights.scm", import.meta.url)) +const javascript_language = fileURLToPath(new URL("./assets/javascript/tree-sitter-javascript.wasm", import.meta.url)) +const typescript_highlights = fileURLToPath(new URL("./assets/typescript/highlights.scm", import.meta.url)) +const typescript_language = fileURLToPath(new URL("./assets/typescript/tree-sitter-typescript.wasm", import.meta.url)) +const markdown_highlights = fileURLToPath(new URL("./assets/markdown/highlights.scm", import.meta.url)) +const markdown_language = fileURLToPath(new URL("./assets/markdown/tree-sitter-markdown.wasm", import.meta.url)) +const markdown_injections = fileURLToPath(new URL("./assets/markdown/injections.scm", import.meta.url)) +const markdown_inline_highlights = fileURLToPath(new URL("./assets/markdown_inline/highlights.scm", import.meta.url)) +const markdown_inline_language = fileURLToPath(new URL("./assets/markdown_inline/tree-sitter-markdown_inline.wasm", import.meta.url)) +const zig_highlights = fileURLToPath(new URL("./assets/zig/highlights.scm", import.meta.url)) +const zig_language = fileURLToPath(new URL("./assets/zig/tree-sitter-zig.wasm", import.meta.url)) // Cached parsers to avoid re-resolving paths on every call let _cachedParsers: FiletypeParserOptions[] | undefined @@ -28,25 +27,25 @@ export function getParsers(): FiletypeParserOptions[] { filetype: "javascript", aliases: ["javascriptreact"], queries: { - highlights: [resolve(dirname(fileURLToPath(import.meta.url)), javascript_highlights)], + highlights: [javascript_highlights], }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), javascript_language), + wasm: javascript_language, }, { filetype: "typescript", aliases: ["typescriptreact"], queries: { - highlights: [resolve(dirname(fileURLToPath(import.meta.url)), typescript_highlights)], + highlights: [typescript_highlights], }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), typescript_language), + wasm: typescript_language, }, { filetype: "markdown", queries: { - highlights: [resolve(dirname(fileURLToPath(import.meta.url)), markdown_highlights)], - injections: [resolve(dirname(fileURLToPath(import.meta.url)), markdown_injections)], + highlights: [markdown_highlights], + injections: [markdown_injections], }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), markdown_language), + wasm: markdown_language, injectionMapping: { "nodeTypes": { "inline": "markdown_inline", @@ -69,16 +68,16 @@ export function getParsers(): FiletypeParserOptions[] { { filetype: "markdown_inline", queries: { - highlights: [resolve(dirname(fileURLToPath(import.meta.url)), markdown_inline_highlights)], + highlights: [markdown_inline_highlights], }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), markdown_inline_language), + wasm: markdown_inline_language, }, { filetype: "zig", queries: { - highlights: [resolve(dirname(fileURLToPath(import.meta.url)), zig_highlights)], + highlights: [zig_highlights], }, - wasm: resolve(dirname(fileURLToPath(import.meta.url)), zig_language), + wasm: zig_language, }, ] } diff --git a/packages/core/src/lib/tree-sitter/parser.worker.ts b/packages/core/src/lib/tree-sitter/parser.worker.ts index f49fdb73a..f31544124 100644 --- a/packages/core/src/lib/tree-sitter/parser.worker.ts +++ b/packages/core/src/lib/tree-sitter/parser.worker.ts @@ -1,18 +1,19 @@ -import { Parser, Query, Tree, Language } from "web-tree-sitter" -import type { Edit, QueryCapture, Range } from "web-tree-sitter" import { mkdir } from "fs/promises" import * as path from "path" +import { fileURLToPath } from "url" +import type { Edit, QueryCapture, Range } from "web-tree-sitter" +import { Language, Parser, Query, Tree } from "web-tree-sitter" +import { isMainThread } from "worker_threads" +import { isBunfsPath, normalizeBunfsPath } from "../bunfs.js" +import { DownloadUtils } from "./download-utils.js" import type { + FiletypeParserOptions, HighlightRange, HighlightResponse, - SimpleHighlight, - FiletypeParserOptions, - PerformanceStats, InjectionMapping, + PerformanceStats, + SimpleHighlight, } from "./types.js" -import { DownloadUtils } from "./download-utils.js" -import { isMainThread } from "worker_threads" -import { isBunfsPath, normalizeBunfsPath } from "../bunfs.js" const self = globalThis @@ -88,9 +89,7 @@ class ParserWorker { await mkdir(path.join(this.tsDataPath, "languages"), { recursive: true }) await mkdir(path.join(this.tsDataPath, "queries"), { recursive: true }) - let { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { - with: { type: "wasm" }, - }) + let treeWasm = fileURLToPath(new URL(import.meta.resolve("web-tree-sitter/tree-sitter.wasm"))) if (isBunfsPath(treeWasm)) { treeWasm = normalizeBunfsPath(path.parse(treeWasm).base) diff --git a/packages/core/src/renderables/Code.test.ts b/packages/core/src/renderables/Code.test.ts index f0081fa90..cd6b3863b 100644 --- a/packages/core/src/renderables/Code.test.ts +++ b/packages/core/src/renderables/Code.test.ts @@ -1,4 +1,5 @@ import { test, expect, beforeEach, afterEach } from "bun:test" +import { sleep } from "../compat/runtime.js" import { CodeRenderable } from "./Code.js" import { SyntaxStyle } from "../syntax-style.js" import { RGBA } from "../lib/RGBA.js" @@ -1109,14 +1110,14 @@ test("CodeRenderable - streaming mode with drawUnstyledText=false waits for new currentRenderer.root.add(codeRenderable) currentRenderer.start() - await Bun.sleep(30) + await sleep(30) expect(codeRenderable.plainText).toBe("const initial = 'hello';") codeRenderable.content = "const updated = 'world';" expect(codeRenderable.plainText).toBe("const initial = 'hello';") - await Bun.sleep(30) + await sleep(30) expect(codeRenderable.plainText).toBe("const updated = 'world';") @@ -2046,7 +2047,7 @@ test("CodeRenderable - streaming with drawUnstyledText=false falls back to unsty currentRenderer.root.add(codeRenderable) currentRenderer.start() - await Bun.sleep(30) + await sleep(30) mockClient.highlightOnce = async () => { throw new Error("Highlighting failed") @@ -2054,7 +2055,7 @@ test("CodeRenderable - streaming with drawUnstyledText=false falls back to unsty codeRenderable.content = "const updated = 'world';" - await Bun.sleep(30) + await sleep(30) expect(codeRenderable.plainText).toBe("const updated = 'world';") diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index 0b479ea0c..36dbf33ed 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -1,5 +1,6 @@ import { Renderable, type RenderableOptions } from "../Renderable.js" import { OptimizedBuffer } from "../buffer.js" +import { stringWidth } from "../compat/runtime.js" import type { RenderContext, LineInfoProvider } from "../types.js" import { RGBA, parseColor } from "../lib/RGBA.js" import { MeasureMode } from "yoga-layout" @@ -158,11 +159,11 @@ class GutterRenderable extends Renderable { for (const sign of this._lineSigns.values()) { if (sign.before) { - const width = Bun.stringWidth(sign.before) + const width = stringWidth(sign.before) this._maxBeforeWidth = Math.max(this._maxBeforeWidth, width) } if (sign.after) { - const width = Bun.stringWidth(sign.after) + const width = stringWidth(sign.after) this._maxAfterWidth = Math.max(this._maxAfterWidth, width) } } @@ -302,7 +303,7 @@ class GutterRenderable extends Renderable { // Draw 'before' sign if present const sign = this._lineSigns.get(logicalLine) if (sign?.before) { - const beforeWidth = Bun.stringWidth(sign.before) + const beforeWidth = stringWidth(sign.before) // Pad to max before width for alignment const padding = this._maxBeforeWidth - beforeWidth currentX += padding diff --git a/packages/core/src/renderables/ScrollBar.ts b/packages/core/src/renderables/ScrollBar.ts index 4a54efd7c..b70d912e2 100644 --- a/packages/core/src/renderables/ScrollBar.ts +++ b/packages/core/src/renderables/ScrollBar.ts @@ -1,4 +1,5 @@ import type { OptimizedBuffer } from "../buffer.js" +import { stringWidth } from "../compat/runtime.js" import { parseColor, RGBA, type ColorInput } from "../lib/index.js" import type { KeyEvent } from "../lib/KeyHandler.js" import { Renderable, type RenderableOptions } from "../Renderable.js" @@ -344,7 +345,7 @@ export class ArrowRenderable extends Renderable { } if (!options.width) { - this.width = Bun.stringWidth(this.getArrowChar()) + this.width = stringWidth(this.getArrowChar()) } } diff --git a/packages/core/src/renderables/ScrollBox.ts b/packages/core/src/renderables/ScrollBox.ts index 528379ac2..7afe51b21 100644 --- a/packages/core/src/renderables/ScrollBox.ts +++ b/packages/core/src/renderables/ScrollBox.ts @@ -770,18 +770,7 @@ export class ScrollBoxRenderable extends BoxRenderable { this._isApplyingStickyScroll = wasApplyingStickyScroll } - // NOTE: This is obviously a workaround for something, - // which is that the bar props are recalculated when the viewport is resized, - // which intially happens onUpdate but is the viewport does not have the correct dimensions yet, - // then when it does, no update is triggered and when we do we are in the middle of a render, - // which just ignores the request. ¯\_(ツ)_/¯ - // TODO: Fix this properly. How? Move yoga to native, get all changes for elements in one go - // and update all renderables in one go before rendering. - // OR: Move this logic to the viewport. IMHO the wrapper and viewport are overkill and not necessary. - // The Scrollbox can be the viewport, we are using translations on the content anyway. - process.nextTick(() => { - this.requestRender() - }) + this.requestRender() } // Setters for reactive properties diff --git a/packages/core/src/renderables/__snapshots__/Code.test.ts.nodejs.snap b/packages/core/src/renderables/__snapshots__/Code.test.ts.nodejs.snap new file mode 100644 index 000000000..30aaf800d --- /dev/null +++ b/packages/core/src/renderables/__snapshots__/Code.test.ts.nodejs.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CodeRenderable - text renders immediately before highlighting completes > text visible after highlighting completes 1`] = ` +"const message = 'hello world'; + +" +`; + +exports[`CodeRenderable - text renders immediately before highlighting completes > text visible before highlighting completes 1`] = ` +"const message = 'hello world'; + +" +`; diff --git a/packages/core/src/renderables/__snapshots__/Diff.test.ts.nodejs.snap b/packages/core/src/renderables/__snapshots__/Diff.test.ts.nodejs.snap new file mode 100644 index 000000000..890bc16ed --- /dev/null +++ b/packages/core/src/renderables/__snapshots__/Diff.test.ts.nodejs.snap @@ -0,0 +1,785 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DiffRenderable - add-only diff split view > split view add-only diff 1`] = ` +" 1 + function newFunction() { + 2 + return true; + 3 + } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - add-only diff unified view > unified view add-only diff 1`] = ` +" 1 + function newFunction() { + 2 + return true; + 3 + } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - asymmetric block with more adds than removes in split view > split view asymmetric block more adds 1`] = ` +" 1 context_before 1 context_before + 2 - remove1 2 + add1 + 3 - remove2 3 + add2 + 4 + add3 + 5 + add4 + 6 + add5 + 4 context_after 7 context_after + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - asymmetric block with more removes than adds in split view > split view asymmetric block more removes 1`] = ` +" 1 context_before 1 context_before + 2 - remove1 2 + add1 + 3 - remove2 3 + add2 + 4 - remove3 + 5 - remove4 + 6 - remove5 + 7 context_after 4 context_after + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - back-to-back change blocks without context lines in split view > split view back-to-back blocks 1`] = ` +" 1 - remove1 1 + add1 + 2 - remove2 2 + add2 + 3 - remove3 3 + add3 + 4 - remove4 4 + add4 + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - can toggle conceal with markdown diff > markdown diff with conceal disabled 1`] = ` +" 1 First line + 2 - Some text **old** + 2 + Some text **boldtext** and *italic* + 3 End line + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - can toggle conceal with markdown diff > markdown diff with conceal enabled 1`] = ` +" 1 First line + 2 - Some text old** + 2 + So text**boldext** and *italic* + 3 End line + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - conceal works in split view > split view markdown diff with conceal disabled 1`] = ` +" 1 First line 1 First line + 2 - Some **old** text 2 + Some **new** text + 3 End line 3 End line + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - conceal works in split view > split view markdown diff with conceal enabled 1`] = ` +" 1 First line 1 First line + 2 - Some old text 2 + Some new text + 3 End line 3 End line + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - consistent left padding for line numbers > 9 > unified view with double-digit line numbers 1`] = ` +" 8 line8 + 9 line9 + 10 - line10_old + 10 + line10_new + 11 line11 + 12 + line12_added + 13 + line13_added + 14 line14 + 15 line15 + 14 - line16_old + 16 + line16_new + + + + + + + + + +" +`; + +exports[`DiffRenderable - diff with only context lines (no changes) > diff with only context lines 1`] = ` +" 1 line1 + 2 line2 + 3 line3 + 4 line4 + 5 line5 + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - invalid diff format shows error with raw diff > invalid diff format with error 1`] = ` +"Error parsing diff: Unknown line 5 "- console.log(\\"Hello\\");" + +--- a/test.js ++++ b/test.js +@@ -a,b +c,d @@ + function hello() { +- console.log("Hello"); ++ console.log("Hello, World!"); + } + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - large line numbers displayed correctly > unified view large line numbers 1`] = ` +" 42 const line42 = 'context'; + 43 const line43 = 'context'; + 44 - const line44 = 'removed'; + 44 + const line44 = 'added'; + 45 const line45 = 'context'; + 46 + const line46 = 'added'; + 47 const line47 = 'context'; + 48 const line48 = 'context'; + 48 - const line49 = 'removed'; + 49 + const line49 = 'changed'; + 50 const line50 = 'context'; + 51 const line51 = 'context'; + + + + + + + + +" +`; + +exports[`DiffRenderable - line numbers hidden for empty alignment lines in split view > split view with hidden line numbers for empty lines 1`] = ` +" 1 + function newFunction() { + 2 + return true; + 3 + } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - line numbers update correctly after resize causes wrapping changes > after resize - line numbers with wrapping 1`] = ` +" 1 function + calculateSomethingVeryComplexWithALongFunctionNameThat + WillWrap() { + 2 - const + oldResultWithAVeryLongVariableNameThatWillDefinitelyWr + apWhenRenderedInASmallerTerminal = 42; + 2 + const + newResultWithAVeryLongVariableNameThatWillDefinitelyWr + apWhenRenderedInASmallerTerminal = 100; + 3 return result; + 4 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - line numbers update correctly after resize causes wrapping changes > before resize - line numbers with no wrapping 1`] = ` +" 1 function calculateSomethingVeryComplexWithALongFunctionNameThatWillWrap() { + 2 - const oldResultWithAVeryLongVariableNameThatWillDefinitelyWrapWhenRenderedInASmallerTerminal = 42; + 2 + const newResultWithAVeryLongVariableNameThatWillDefinitelyWrapWhenRenderedInASmallerTerminal = 100; + 3 return result; + 4 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - multi-line diff split view > split view multi-line diff 1`] = ` +" 1 function add(a, b) { 1 function add(a, b) { + 2 return a + b; 2 return a + b; + 3 } 3 } + 4 4 + 5 + function subtract(a, b) { + 6 + return a - b; + 7 + } + 8 + + 5 function multiply(a, b) { 9 function multiply(a, b) { + 6 - return a * b; 10 + return a * b * 1; + 7 } 11 } + + + + + + + + + +" +`; + +exports[`DiffRenderable - multi-line diff unified view > unified view multi-line diff 1`] = ` +" 1 function add(a, b) { + 2 return a + b; + 3 } + 4 + 5 + function subtract(a, b) { + 6 + return a - b; + 7 + } + 8 + + 9 function multiply(a, b) { + 6 - return a * b; + 10 + return a * b * 1; + 11 } + + + + + + + + +" +`; + +exports[`DiffRenderable - multiple hunks in split view > split view multiple hunks 1`] = ` +" 1 function first() { 1 function first() { + 2 - return 1; 2 + return "one"; + 3 } 3 } + 15 function second() { 15 function second() { + 16 var x = 10; 16 var x = 10; + 17 + var y = 20; + 17 return x; 18 return x; + 18 } 19 } + 30 function third() { 31 function third() { + 31 - console.log("old"); 32 + console.log("new"); + 32 } 33 } + + + + + + + + + +" +`; + +exports[`DiffRenderable - multiple hunks in unified view > unified view multiple hunks 1`] = ` +" 1 function first() { + 2 - return 1; + 2 + return "one"; + 3 } + 15 function second() { + 16 var x = 10; + 17 + var y = 20; + 18 return x; + 19 } + 31 function third() { + 31 - console.log("old"); + 32 + console.log("new"); + 33 } + + + + + + + +" +`; + +exports[`DiffRenderable - no newline at end of file in split view > split view with no newline marker 1`] = ` +" 1 line1 1 line1 + 2 line2 2 line2 + 3 - line3 + 3 + line3_modified + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - no newline at end of file in unified view > unified view with no newline marker 1`] = ` +" 1 line1 + 2 line2 + 3 - line3 + 3 + line3_modified + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - remove-only diff split view > split view remove-only diff 1`] = ` +" 1 - function oldFunction() { + 2 - return false; + 3 - } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - remove-only diff unified view > unified view remove-only diff 1`] = ` +" 1 - function oldFunction() { + 2 - return false; + 3 - } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - split view alignment with empty lines > split view alignment 1`] = ` +" 1 line1 1 line1 + 2 + line2_added + 3 + line3_added + 4 + line4_added + 2 line5 5 line5 + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - split view renders correctly > split view simple diff 1`] = ` +" 1 function hello() { 1 function hello() { + 2 - console.log("Hello"); 2 + console.log("Hello, World!"); + 3 } 3 } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - unified view renders correctly > unified view simple diff 1`] = ` +" 1 function hello() { + 2 - console.log("Hello"); + 2 + console.log("Hello, World!"); + 3 } + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - very long lines wrapping multiple times in split view > split view multi-wrap lines 1`] = ` +" 1 short line 1 short line + 2 - This is an extremely long line 2 + This is an extremely long line + that will definitely wrap multiple that has been modified and will + times when rendered in a split definitely wrap multiple times + view with word wrapping enabled when rendered in a split view with + because it contains so many words word wrapping enabled because it + and characters contains so many words and + characters and even more content + 3 another short line 3 another short line + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - wrapMode works in unified view > wrapMode-none 1`] = ` +" 1 function hello() { + 2 - console.log("This is a very long line that should wrap when wrapMode is s + 2 + console.log("This is a very long line that has been modified and should w + 3 } + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - wrapMode works in unified view > wrapMode-none 2`] = ` +" 1 function hello() { + 2 - console.log("This is a very long line that should wrap when wrapMode is s + 2 + console.log("This is a very long line that has been modified and should w + 3 } + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - wrapMode works in unified view > wrapMode-word 1`] = ` +" 1 function hello() { + 2 - console.log("This is a very long line that should wrap when wrapMode is + set to word but not when it is set to none"); + 2 + console.log("This is a very long line that has been modified and should + wrap when wrapMode is set to word but not when it is set to none"); + 3 } + + + + + + + + + + + + + + +" +`; diff --git a/packages/core/src/renderables/__snapshots__/Text.test.ts.nodejs.snap b/packages/core/src/renderables/__snapshots__/Text.test.ts.nodejs.snap new file mode 100644 index 000000000..47d067a66 --- /dev/null +++ b/packages/core/src/renderables/__snapshots__/Text.test.ts.nodejs.snap @@ -0,0 +1,421 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TextRenderable Selection > Absolute Positioned Box with Text > should handle width:100% text in absolute positioned box with constrained maxWidth 1`] = ` +" + + + + + + + This is an extremely long piece of text + that needs to wrap multiple times within + the constrained width of the absolutely + positioned container box with significant + padding on all sides. + + + +" +`; + +exports[`TextRenderable Selection > Absolute Positioned Box with Text > should render multiple text elements in absolute positioned box with proper spacing 1`] = ` +" + + + ┌───────────────────────────────────────────┐ + │ │ + │ System Update │ + │ │ + │ A new version is available with bug │ + │ fixes and performance improvements. │ + │ │ + │ Click to install │ + │ │ + └───────────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`TextRenderable Selection > Absolute Positioned Box with Text > should render text fully visible in absolute positioned box at various positions 1`] = ` +" + ┌──────────────────────────────────────┐ + │ Error: File not found in the │ + │ specified directory path │ + └──────────────────────────────────────┘ + + + + + + + + + + + + + + + + ─────────────────────────────────── + Success: Operation completed + successfully! + ─────────────────────────────────── + +" +`; + +exports[`TextRenderable Selection > Absolute Positioned Box with Text > should render text in absolute positioned box with padding and borders correctly 1`] = ` +" + + │ │ + │ │ + │ Important Notification │ + │ │ + │ │ + │ This is a longer message that should wrap properly within + │ │ + │ │ + + + + + + + + + + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render TextNode text composition correctly 1`] = ` +"First Second Third + + + + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render basic text content correctly 1`] = ` +" + + + Hello World + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render empty buffer correctly 1`] = ` +" + + + + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render multiline text content correctly 1`] = ` +" + Line 1: Hello + Line 2: World + Line 3: Testing + Line 4: Multiline +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render text positioning correctly 1`] = ` +"Top + + Mid + + Bot +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render text with character wrapping correctly 1`] = ` +"This is a very +long text that +should wrap to +multiple lines +when wrap is en +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render text with graphemes/emojis correctly 1`] = ` +" + +Hello 🌍 World 👋 + Test 🚀 Emoji + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render text with tab indicator correctly 1`] = ` +"Line 1→ Tabbed +Line 2→ → Double tab + + + +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render wrapped multiline text correctly 1`] = ` +" +First li +ne with +long con +tent +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render wrapped text with different content 1`] = ` +" + ABCDEFGHIJ + KLMNOPQRST + UVWXYZ abc + defghijklm +" +`; + +exports[`TextRenderable Selection > Text Content Snapshots > should render wrapped text with emojis and graphemes 1`] = ` +" Hello 🌍 Wor + ld 👋 This i + s a test wit + h emojis 🚀 + that should +" +`; + +exports[`TextRenderable Selection > Text Node Dimension Updates > should handle multiple text node updates with complex layout changes 1`] = ` +"First part +Middle text +Bottom text + + + + + + + +" +`; + +exports[`TextRenderable Selection > Text Node Dimension Updates > should handle multiple text node updates with complex layout changes 2`] = ` +"First of +a +sentence +partthat +will wrap +Middle text +Bottom text + + + +" +`; + +exports[`TextRenderable Selection > Text Node Dimension Updates > should update dimensions and reposition subsequent elements when text nodes expand 1`] = ` +"Short +Second text + + + +" +`; + +exports[`TextRenderable Selection > Text Node Dimension Updates > should update dimensions and reposition subsequent elements when text nodes expand 2`] = ` +"Short text that will + definitely wrap +Second text + + +" +`; + +exports[`TextRenderable Selection > Width/Height Setter Layout Tests > should not shrink box when height is set via setter in column layout with text 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`TextRenderable Selection > Width/Height Setter Layout Tests > should not shrink box when minHeight is set via setter in column layout with text 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`TextRenderable Selection > Width/Height Setter Layout Tests > should not shrink box when minWidth is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection > Width/Height Setter Layout Tests > should not shrink box when width is set from undefined via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection > Width/Height Setter Layout Tests > should not shrink box when width is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should compare char vs word wrapping with same content 1`] = ` +"Hello +wonderful +world of +text +wrapping +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should correctly wrap text when updating content via text.content 1`] = ` +"Short text + + + + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should correctly wrap text when updating content via text.content 2`] = ` +"This is a much +longer text that +should definitely +wrap to multiple +lines +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should dynamically change wrap mode 1`] = ` +"The quick +brown fox +jumps + + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should handle long words that exceed wrap width in word mode 1`] = ` +"ABCDEFGHIJ +KLMNOPQRST +UVWXYZ + + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should handle word wrapping with hyphens and dashes 1`] = ` +"self- +contained +multi-line +text- +wrapping +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should handle word wrapping with punctuation 1`] = ` +"Hello, +World. +Test- +Example/ +Path +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should handle word wrapping with single character words 1`] = ` +"a b c d +e f g h +i j k l +m n o p + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should preserve empty lines with word wrapping 1`] = ` +"First +line + +Third +line +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should wrap at character boundaries when using char mode 1`] = ` +"The quick brown + fox jumps over + the lazy dog + + +" +`; + +exports[`TextRenderable Selection > Word Wrapping > should wrap at word boundaries when using word mode 1`] = ` +"The quick +brown fox +jumps over the +lazy dog + +" +`; diff --git a/packages/core/src/renderables/__snapshots__/TextTable.test.ts.nodejs.snap b/packages/core/src/renderables/__snapshots__/TextTable.test.ts.nodejs.snap new file mode 100644 index 000000000..e9925bb85 --- /dev/null +++ b/packages/core/src/renderables/__snapshots__/TextTable.test.ts.nodejs.snap @@ -0,0 +1,215 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TextTableRenderable > balanced fitter keeps constrained columns visually closer > fitter balanced constrained 1`] = ` +"┌────────┬────────┬─────────┬─────────┬────────┬─────────┐ +│Provider│Compute │Storage │Pricing │Regions │Use Cases│ +│ │Services│Solutions│Model │ │ │ +├────────┼────────┼─────────┼─────────┼────────┼─────────┤ +│Amazon │EC2 │S3 tiers,│Pay as │Global │Enterpris│ +│Web │instance│ EBS, │you go, │regions │e migrati│ +│Services│s with e│EFS, and │reserved │and │on, analy│ +│ │xtensive│archive │terms, │many │tics, ML,│ +│ │ options│classes │and │edge │ and back│ +│ │ for gen│for long │discounte│location│end servi│ +│ │eral, me│retention│d spot ca│s │ces │ +│ │mory, an│ │pacity │ │ │ +│ │d accele│ │ │ │ │ +│ │rated wo│ │ │ │ │ +│ │rkloads │ │ │ │ │ +└────────┴────────┴─────────┴─────────┴────────┴─────────┘ +" +`; + +exports[`TextTableRenderable > balanced fitter keeps constrained columns visually closer > fitter proportional constrained 1`] = ` +"┌────┬─────────────┬─────────┬─────────┬───────┬─────────┐ +│Prov│Compute │Storage │Pricing │Regions│Use Cases│ +│ider│Services │Solutions│Model │ │ │ +├────┼─────────────┼─────────┼─────────┼───────┼─────────┤ +│Amaz│EC2 │S3 tiers,│Pay as │Global │Enterpris│ +│on W│instances │ EBS, │you go, │regions│e migrati│ +│eb S│with │EFS, and │reserved │ and ma│on, analy│ +│ervi│extensive │archive │terms, │ny edge│tics, ML,│ +│ces │options for │classes │and │ locati│ and back│ +│ │general, │for long │discounte│ons │end servi│ +│ │memory, and │retention│d spot ca│ │ces │ +│ │accelerated │ │pacity │ │ │ +│ │workloads │ │ │ │ │ +└────┴─────────────┴─────────┴─────────┴───────┴─────────┘ + + +" +`; + +exports[`TextTableRenderable > keeps borders aligned with CJK and emoji content > unicode border alignment 1`] = ` +"┌──────┬──────────────┐ +│Locale│Sample │ +├──────┼──────────────┤ +│ja-JP │東京で寿司 🍣 │ +├──────┼──────────────┤ +│zh-CN │你好世界 🚀 │ +├──────┼──────────────┤ +│ko-KR │한글 테스트 😄│ +└──────┴──────────────┘ + + + + + + + +" +`; + +exports[`TextTableRenderable > keeps full wrapped table layouts after a wide-to-narrow demo-style resize > demo resize expected primary table 1`] = ` +"┌─────────────────────────┬─────────────┬────────────────────────────┐ +│Task │Owner │ETA │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Wrap regression in │core │done after validating none, │ +│operational status │platform and │word, and char wrap modes │ +│dashboard with dynamic │runtime │across narrow, medium, wide,│ +│row heights and │reliability │ and ultra-wide terminal │ +│constrained layout │squad │widths │ +│validation │ │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Unicode layout │render │in review with follow-up │ +│stabilization for mixed │pipeline │checks for border style │ +│Latin, punctuation, │maintainers │transitions, cell padding │ +│symbols, and long │with │variants, and selection │ +│identifiers in adjacent │fallback │range consistency │ +│columns │shaping │ │ +│ │support │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Snapshot pass for table │qa │today pending final │ +│rendering in content │automation │baseline updates for │ +│mode and full mode with │and visual │oversized fixtures that │ +│heavy and double border │diff triage │intentionally stress │ +│combinations │group │wrapping behavior on high- │ +│ │ │resolution terminals │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Document edge cases │developer │planned for this sprint │ +│where long tokens │experience │once final reproducible │ +│without spaces force │and docs │examples are captured and │ +│char wrapping and reveal │tooling │linked to regression │ +│per-cell clipping │ │tracking tickets │ +│regressions │ │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Performance sweep of │runtime │scheduled after review, │ +│wrapping algorithm under │performance │with benchmark runs on │ +│large datasets to │task force │laptop and desktop │ +│confirm stable frame │ │terminals at 200-plus │ +│times during rapid key │ │column widths │ +│toggling │ │ │ +└─────────────────────────┴─────────────┴────────────────────────────┘ +" +`; + +exports[`TextTableRenderable > keeps full wrapped table layouts after a wide-to-narrow demo-style resize > demo resize expected unicode table 1`] = ` +"┌─────┬──────────────────────────────────────────────────────────────┐ +│Colum│Wrapped Text │ +│n │ │ +├─────┼──────────────────────────────────────────────────────────────┤ +│mixed│CJK and emoji wrapping stress case: こんにちは世界 and │ +│-lang│안녕하세요 세계 and 你好,世界 followed by long English prose │ +│uages│that keeps flowing to test whether each cell wraps naturally │ +│ │even when the terminal is extremely wide and the row still │ +│ │needs multiple visual lines for readability 🌍🚀 │ +├─────┼──────────────────────────────────────────────────────────────┤ +│emoji│Faces 😀😃😄😁😆 plus symbols 🧪📦🛰️🔧📊 mixed with version │ +│-and-│tags like release-candidate-build-2026-02-very-long-token- │ +│symbo│without-breaks to ensure char wrapping remains stable and no │ +│ls │glyph alignment issues appear at column boundaries │ +├─────┼──────────────────────────────────────────────────────────────┤ +│long-│長文の日本語テキストと中文段落和한국어문장을連続して配置し、 │ +│cjk- │その後に additional English context describing renderer │ +│phras│behavior, border intersection handling, and selection │ +│e │extraction so that this single cell remains a reliable │ +│ │wrapping torture test. │ +├─────┼──────────────────────────────────────────────────────────────┤ +│mixed│Wrap behavior with punctuation-heavy content: [alpha]{beta}( │ +│-punc│gamma)|epsilon| then repeated fragments, commas, │ +│tuati│semicolons, and slashes to verify token boundaries do not │ +│on │break border drawing logic or spacing consistency in │ +│ │neighboring columns. │ +└─────┴──────────────────────────────────────────────────────────────┘ +" +`; + +exports[`TextTableRenderable > rebuilds table when content setter is used > content setter update 1`] = ` +"┌─────┬───────┐ +│Col 1│Col 2 │ +├─────┼───────┤ +│row-1│updated│ +├─────┼───────┤ +│row-2│active │ +└─────┴───────┘ + + + + + + + + + +" +`; + +exports[`TextTableRenderable > renders a basic table with styled cell chunks > basic table 1`] = ` +" + ┌─────┬──────┬───────────────────┐ + │Name │Status│Notes │ + ├─────┼──────┼───────────────────┤ + │Alpha│OK │All systems nominal│ + ├─────┼──────┼───────────────────┤ + │Bravo│WARN │Pending checks │ + └─────┴──────┴───────────────────┘ + + + + + + + + +" +`; + +exports[`TextTableRenderable > wraps CJK and emoji without grapheme duplication > unicode wrapping 1`] = ` +"┌───┬────────────────────────┐ +│Ite│Details │ +│m │ │ +├───┼────────────────────────┤ +│mix│東京界 🌍 emoji │ +│ed │wrapping continues │ +│ │across lines for width │ +│ │checks │ +├───┼────────────────────────┤ +│emo│Faces 😀😃😄 should │ +│ji │remain stable │ +└───┴────────────────────────┘ + + + + +" +`; + +exports[`TextTableRenderable > wraps content and fits columns when width is constrained > wrapped constrained width 1`] = ` +"┌──┬─────────────────────────────┐ +│ID│Description │ +├──┼─────────────────────────────┤ +│1 │This is a long sentence that │ +│ │should wrap across multiple │ +│ │visual lines │ +├──┼─────────────────────────────┤ +│2 │Short │ +└──┴─────────────────────────────┘ + + + + + + + +" +`; diff --git a/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts b/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts index 079e1b8b9..57cd54c63 100644 --- a/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts +++ b/packages/core/src/renderables/__tests__/LineNumberRenderable.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test" +import { sleep } from "../../compat/runtime.js" import { createTestRenderer } from "../../testing/test-renderer.js" import { TextBufferRenderable } from "../TextBufferRenderable.js" import { LineNumberRenderable } from "../LineNumberRenderable.js" @@ -1185,9 +1186,9 @@ describe("LineNumberRenderable", () => { // Wait for render and highlighting await renderOnce() // Give highlighting time to complete (increased for CI) - await Bun.sleep(1000) + await sleep(1000) await renderOnce() - await Bun.sleep(100) + await sleep(100) await renderOnce() frame = captureCharFrame() @@ -1238,7 +1239,7 @@ describe("LineNumberRenderable", () => { // First render await renderOnce() - await Bun.sleep(50) + await sleep(50) await renderOnce() let frame = captureCharFrame() @@ -1252,7 +1253,7 @@ describe("LineNumberRenderable", () => { codeRenderable.content = "line 1\nline 2\nline 3\nline 4\nline 5" await renderOnce() - await Bun.sleep(50) + await sleep(50) await renderOnce() frame = captureCharFrame() @@ -1312,7 +1313,7 @@ describe("LineNumberRenderable", () => { codeRenderable.filetype = "typescript" await renderOnce() - await Bun.sleep(100) + await sleep(100) await renderOnce() frame = captureCharFrame() diff --git a/packages/core/src/renderables/__tests__/Markdown.code-colors.test.ts b/packages/core/src/renderables/__tests__/Markdown.code-colors.test.ts index efc148381..ca4cb8769 100644 --- a/packages/core/src/renderables/__tests__/Markdown.code-colors.test.ts +++ b/packages/core/src/renderables/__tests__/Markdown.code-colors.test.ts @@ -1,4 +1,5 @@ import { test, expect, beforeEach, afterEach } from "bun:test" +import { sleep } from "../../compat/runtime.js" import { MarkdownRenderable, type MarkdownOptions } from "../Markdown.js" import { CodeRenderable } from "../Code.js" import { SyntaxStyle } from "../../syntax-style.js" @@ -100,7 +101,7 @@ test("unsupported fenced code blocks keep inherited markdown fg/bg after highlig expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const codeBlock = md._blockStates[0]?.renderable as CodeRenderable @@ -130,7 +131,7 @@ test("fenced tsx code blocks normalize the language before highlighting", async expect(mockTreeSitterClient.highlightCalls[0]?.filetype).toBe("typescriptreact") mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() }) @@ -152,7 +153,7 @@ test("updating fenced code blocks reapplies normalized filetypes", async () => { expect(codeBlock.filetype).toBe("javascriptreact") mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() md.content = "```tsx\nconst view =
Hello
\n```" @@ -163,7 +164,7 @@ test("updating fenced code blocks reapplies normalized filetypes", async () => { expect(mockTreeSitterClient.highlightCalls.at(-1)?.filetype).toBe("typescriptreact") mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() }) @@ -220,7 +221,7 @@ test("updating markdown fg/bg rerenders markdown fallback renderables", async () renderer.root.add(md) await renderer.idle() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const paragraphBlock = md._blockStates[0]?.renderable as CodeRenderable @@ -232,7 +233,7 @@ test("updating markdown fg/bg rerenders markdown fallback renderables", async () md.bg = nextBg renderer.requestRender() await renderer.idle() - await Bun.sleep(10) + await sleep(10) await renderer.idle() expect(md._blockStates[0]?.renderable).toBe(paragraphBlock) diff --git a/packages/core/src/renderables/__tests__/Markdown.test.ts b/packages/core/src/renderables/__tests__/Markdown.test.ts index c3dd32c12..9237ab8d0 100644 --- a/packages/core/src/renderables/__tests__/Markdown.test.ts +++ b/packages/core/src/renderables/__tests__/Markdown.test.ts @@ -1,4 +1,5 @@ import { test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test" +import { sleep } from "../../compat/runtime.js" import { MarkdownRenderable, type MarkdownOptions } from "../Markdown.js" import { CodeRenderable } from "../Code.js" import { TextRenderable } from "../Text.js" @@ -74,7 +75,7 @@ async function renderMarkdownRenderable(md: MarkdownRenderable, timeoutMs: numbe await renderOnce() while (hasPendingMarkdownParagraphHighlights() && Date.now() - startedAt < timeoutMs) { - await Bun.sleep(10) + await sleep(10) await renderOnce() } @@ -780,7 +781,7 @@ test("code block concealment is disabled by default", async () => { expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const frame = captureFrame() @@ -807,7 +808,7 @@ test("code block concealment can be enabled with concealCode", async () => { expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const frame = captureFrame() @@ -835,7 +836,7 @@ test("toggling concealCode updates existing code block renderables", async () => expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const frameBefore = captureFrame() @@ -847,7 +848,7 @@ test("toggling concealCode updates existing code block renderables", async () => expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() const frameAfter = captureFrame() @@ -1455,7 +1456,7 @@ test("streaming code blocks with concealCode=true do not flash unconcealed markd expect(mockTreeSitterClient.isHighlighting()).toBe(true) mockTreeSitterClient.resolveAllHighlightOnce() - await Bun.sleep(10) + await sleep(10) await renderer.idle() recorder.stop() diff --git a/packages/core/src/renderables/__tests__/Textarea.buffer.test.ts b/packages/core/src/renderables/__tests__/Textarea.buffer.test.ts index 0807446ee..bbd94fce7 100644 --- a/packages/core/src/renderables/__tests__/Textarea.buffer.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.buffer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { stringWidth } from "../../compat/runtime.js" import { createTestRenderer, type TestRenderer, type MockInput } from "../../testing/test-renderer.js" import { createTextareaRenderable } from "./renderable-test-utils.js" @@ -464,7 +465,7 @@ describe("Textarea - Buffer Tests", () => { expect(visualCursor!.logicalCol).toBe(3) }) - it("should set cursor to end of content using cursorOffset setter and Bun.stringWidth", async () => { + it("should set cursor to end of content using cursorOffset setter and stringWidth", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { initialValue: "", width: 40, @@ -475,11 +476,11 @@ describe("Textarea - Buffer Tests", () => { const content = "Hello World" editor.setText(content) - editor.cursorOffset = Bun.stringWidth(content) + editor.cursorOffset = stringWidth(content) const visualCursor = editor.visualCursor expect(visualCursor).not.toBe(null) - expect(visualCursor!.offset).toBe(Bun.stringWidth(content)) + expect(visualCursor!.offset).toBe(stringWidth(content)) expect(visualCursor!.logicalRow).toBe(0) expect(visualCursor!.logicalCol).toBe(content.length) expect(visualCursor!.visualCol).toBe(content.length) diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index 04073ef3c..4abc1b58b 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { sleep } from "../../compat/runtime.js" import { createTestRenderer, type TestRenderer, type MockMouse, type MockInput } from "../../testing/test-renderer.js" import { createTextareaRenderable } from "./renderable-test-utils.js" import { RGBA } from "../../lib/RGBA.js" @@ -1371,7 +1372,7 @@ describe("Textarea - Selection Tests", () => { // Scroll up with mouse wheel await currentMouse.scroll(editor.x, editor.y + 1, "up") - await Bun.sleep(100) + await sleep(100) const selectionAfter = editor.getSelection() const selectedTextAfter = editor.getSelectedText() diff --git a/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.nodejs.snap b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.nodejs.snap new file mode 100644 index 000000000..7f74147f0 --- /dev/null +++ b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.nodejs.snap @@ -0,0 +1,89 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LineNumber in ScrollBox - Simple Core Test > LineNumber with Code in ScrollBox should wrap content height 1`] = ` +" 1 function test() { + 2 return true; + 3 } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`LineNumber in ScrollBox - Simple Core Test > Multiple LineNumber blocks in ScrollBox should each wrap content 1`] = ` +" 1 const x = 1; + 1 const y = 2; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; diff --git a/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.nodejs.snap b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.nodejs.snap new file mode 100644 index 000000000..25f38822c --- /dev/null +++ b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.nodejs.snap @@ -0,0 +1,457 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LineNumberRenderable in ScrollBox > ScrollBox with horizontal and vertical scrolling - dimensions stable 1`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 const veryLongVariableName1 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 2 const veryLongVariableName2 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 3 const veryLongVariableName3 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 4 const veryLongVariableName4 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 5 const veryLongVariableName5 = "This is a │ +└────────────────────────────────────────────────┘ + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > ScrollBox with horizontal and vertical scrolling - dimensions stable 2`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 const veryLongVariableName1 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 2 const veryLongVariableName2 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 3 const veryLongVariableName3 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 4 const veryLongVariableName4 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 5 const veryLongVariableName5 = "This is a │ +└────────────────────────────────────────────────┘ + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > ScrollBox with horizontal and vertical scrolling - dimensions stable 3`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 const veryLongVariableName1 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 2 const veryLongVariableName2 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 3 const veryLongVariableName3 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 4 const veryLongVariableName4 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 5 const veryLongVariableName5 = "This is a │ +└────────────────────────────────────────────────┘ + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > ScrollBox with horizontal and vertical scrolling - dimensions stable 4`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 const veryLongVariableName1 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 2 const veryLongVariableName2 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 3 const veryLongVariableName3 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 4 const veryLongVariableName4 = "This is a │ +│ very long line that should require │ +│ horizontal scrolling to view completely"; │ +│ 5 const veryLongVariableName5 = "This is a │ +└────────────────────────────────────────────────┘ + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > gutter width changes with line count - verify remeasure 1`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ │ +│ │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > gutter width changes with line count - verify remeasure 2`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > gutter width changes with line count - verify remeasure 3`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > line colors span full width in ScrollBox 1`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└────────────────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > line colors span full width in ScrollBox 2`] = ` +"┌────────────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└────────────────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > multiple Code renderables with line numbers in ScrollBox - correct dimensions 1`] = ` +"┌────────────────────────────────────────────────┐ █ +│ 1 function test1() { │ █ +│ 2 console.log("Line 1"); │ █ +│ 3 return 1; │ █ +│ 4 } │ █ +│ 5 function test2() { │ █ +│ 6 console.log("Line 2"); │ █ +└────────────────────────────────────────────────┘ █ + █ + █ +┌────────────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +└────────────────────────────────────────────────┘ + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > multiple Code renderables with line numbers in ScrollBox - correct dimensions 2`] = ` +"│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +└────────────────────────────────────────────────┘ + + +┌────────────────────────────────────────────────┐ █ +│ 1 function test1() { │ █ +│ 2 console.log("Line 1"); │ █ +│ 3 return 1; │ █ +│ 4 } │ █ +│ 5 function test2() { │ █ +│ 6 console.log("Line 2"); │ █ +└────────────────────────────────────────────────┘ █ + █ + █ +┌────────────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +" +`; + +exports[`LineNumberRenderable in ScrollBox > nested boxes with different border styles - dimensions correct 1`] = ` +"╔═════════════════════════════════════════════════════╗ +║ ║ +║ ║ +║ ╭───────────────────────────────────────────╮ ║ +║ │ │ ║ +║ │ 1 function test1() { │ ║ +║ │ 2 console.log("Line 1"); │ ║ +║ │ 3 return 1; │ ║ +║ │ 4 } │ ║ +║ │ 5 function test2() { │ ║ +║ │ 6 console.log("Line 2"); │ ║ +║ │ 7 return 2; │ ║ +║ │ 8 } │ ║ +║ │ 9 function test3() { │ ║ +║ │ 10 console.log("Line 3"); │ ║ +║ │ 11 return 3; │ ║ +║ │ │ ║ +║ ╰───────────────────────────────────────────╯ ║ +╚═════════════════════════════════════════════════════╝ + +" +`; + +exports[`LineNumberRenderable in ScrollBox > nested boxes with different border styles - dimensions correct 2`] = ` +"╔═════════════════════════════════════════════════════╗ +║ ║ +║ ║ +║ ╭───────────────────────────────────────────╮ ║ +║ │ │ ║ +║ │ 1 function test1() { │ ║ +║ │ 2 console.log("Line 1"); │ ║ +║ │ 3 return 1; │ ║ +║ │ 4 } │ ║ +║ │ 5 function test2() { │ ║ +║ │ 6 console.log("Line 2"); │ ║ +║ │ 7 return 2; │ ║ +║ │ 8 } │ ║ +║ │ 9 function test3() { │ ║ +║ │ 10 console.log("Line 3"); │ ║ +║ │ 11 return 3; │ ║ +║ │ │ ║ +║ ╰───────────────────────────────────────────╯ ║ +╚═════════════════════════════════════════════════════╝ + +" +`; + +exports[`LineNumberRenderable in ScrollBox > single Code renderable in ScrollBox - scroll and verify dimensions 1`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > single Code renderable in ScrollBox - scroll and verify dimensions 2`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > single Code renderable in ScrollBox - scroll and verify dimensions 3`] = ` +"┌──────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line 2"); │ +│ 7 return 2; │ +│ 8 } │ +│ 9 function test3() { │ +│ 10 console.log("Line 3"); │ +└──────────────────────────────────────┘ + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > single Code renderable with line numbers in ScrollBox - correct dimensions 1`] = ` +"┌────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line │ +│ 1"); │ +│ 3 return 1; │ +│ 4 } │ +│ 5 function test2() { │ +│ 6 console.log("Line │ +│ 2"); │ +└────────────────────────────┘ + + + + + + + + + + +" +`; + +exports[`LineNumberRenderable in ScrollBox > viewport culling with line numbers - dimensions stable 1`] = ` +"┌───────────────────────────────────────────┐ ▀ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +└───────────────────────────────────────────┘ + +┌───────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +└───────────────────────────────────────────┘ + +┌───────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +└───────────────────────────────────────────┘ +" +`; + +exports[`LineNumberRenderable in ScrollBox > viewport culling with line numbers - dimensions stable 2`] = ` +"│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +└───────────────────────────────────────────┘ + +┌───────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ +└───────────────────────────────────────────┘ + +┌───────────────────────────────────────────┐ +│ 1 function test1() { │ +│ 2 console.log("Line 1"); │ +│ 3 return 1; │ +│ 4 } │ ▄ +└───────────────────────────────────────────┘ + +┌───────────────────────────────────────────┐ +" +`; diff --git a/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.nodejs.snap b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.nodejs.snap new file mode 100644 index 000000000..e20854b32 --- /dev/null +++ b/packages/core/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.nodejs.snap @@ -0,0 +1,158 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LineNumberRenderable > combines line number offset with hidden line numbers 1`] = ` +" 42 Line 1 + Line 2 + 44 Line 3 + Line 4 + 46 Line 5 + + + + + +" +`; + +exports[`LineNumberRenderable > hides line numbers for specific lines 1`] = ` +" 1 Line 1 + Line 2 + 3 Line 3 + Line 4 + 5 Line 5 + + + + + +" +`; + +exports[`LineNumberRenderable > maintains consistent left padding for all line numbers 1`] = ` +" 1 Line 1 + 2 Line 2 + 3 Line 3 + 4 Line 4 + 5 Line 5 + 6 Line 6 + 7 Line 7 + 8 Line 8 + 9 Line 9 + 10 Line 10 + 11 Line 11 + 12 Line 12 + + + +" +`; + +exports[`LineNumberRenderable > maintains stable visual line count when scrolling and typing with word wrap 1`] = ` +" + ┌───────────────────────────────┐ + │ Ctrl+Y to redo │ + │ 36 │ + │ 37 VIEW: │ + │ 38 • Shift+W to toggle │ + │ wrap mode (word/char/ │ + │ none) │ + │ 39 • Shift+L to toggle │ + │ line numbers │ + │ 40 │ + │ 41 FEATURES: │ + │ 42 ✓ Grapheme-aware │ + │ cursor movement │ + │ 43 ✓ Unicode (emoji 🌟 │ + │ and CJK 世界, 你好世界, │ + │ 中文, 한글) │ + │ 44 ✓ Incremental editing │ + │ 45 ✓ Text wrapping and │ + │ viewport management │ + │ 46 ✓ Undo/redo support │ + │ 47 ✓ Word-based │ + │ navigation and deletion │ + │ 48 ✓ Text selection with │ + │ shift keys │ + │ 49 │ + │ 50 Press ESC to return to │ + │ main menu │ + └───────────────────────────────┘ + +" +`; + +exports[`LineNumberRenderable > maintains stable visual line count when scrolling and typing with word wrap 2`] = ` +" + ┌───────────────────────────────┐ + │ Ctrl+Y to redo │ + │ 36 │ + │ 37 VIEW: │ + │ 38 • Shift+W to toggle │ + │ wrap mode (word/char/ │ + │ none) │ + │ 39 • Shift+L to toggle │ + │ line numbers │ + │ 40 │ + │ 41 FEATURES: │ + │ 42 ✓ Grapheme-aware │ + │ cursor movement │ + │ 43 ✓ Unicode (emoji 🌟 │ + │ and CJK 世界, 你好世界, │ + │ 中文, 한글) │ + │ 44 ✓ Incremental editing │ + │ 45 ✓ Text wrapping and │ + │ viewport management │ + │ 46 ✓ Undo/redo support │ + │ 47 ✓ Word-based │ + │ navigation and deletion │ + │ 48 ✓ Text selection with │ + │ shift keys │ + │ 49 a │ + │ 50 Press ESC to return to │ + │ main menu │ + └───────────────────────────────┘ + +" +`; + +exports[`LineNumberRenderable > renders line numbers correctly 1`] = ` +" 1 Line 1 + 2 Line 2 + 3 Line 3 + + + + + + + +" +`; + +exports[`LineNumberRenderable > renders line numbers for wrapping text 1`] = ` +" 1 Line 1 is very l + ong and should w + rap around multi + ple lines + + + + + + +" +`; + +exports[`LineNumberRenderable > renders line numbers with offset 1`] = ` +" 42 Line 1 + 43 Line 2 + 44 Line 3 + + + + + + + +" +`; diff --git a/packages/core/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.nodejs.snap b/packages/core/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.nodejs.snap new file mode 100644 index 000000000..09685c112 --- /dev/null +++ b/packages/core/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.nodejs.snap @@ -0,0 +1,387 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Textarea - Rendering Tests > Absolute Positioned Box with Textarea > should handle width:100% textarea in absolute positioned box with constrained maxWidth 1`] = ` +" + + + + + + + This is an extremely long piece of text + that needs to wrap multiple times within + the constrained width of the absolutely + positioned container box with significant + padding on all sides. + + + +" +`; + +exports[`Textarea - Rendering Tests > Absolute Positioned Box with Textarea > should render multiple textarea elements in absolute positioned box with proper spacing 1`] = ` +" + + + ┌───────────────────────────────────────────┐ + │ │ + │ System Update │ + │ │ + │ A new version is available with bug │ + │ fixes and performance improvements. │ + │ │ + │ Click to install │ + │ │ + └───────────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Absolute Positioned Box with Textarea > should render textarea fully visible in absolute positioned box at various positions 1`] = ` +" + ┌──────────────────────────────────────┐ + │ Error: File not found in the │ + │ specified directory path │ + └──────────────────────────────────────┘ + + + + + + + + + + + + + + + + ─────────────────────────────────── + Success: Operation completed + successfully! + ─────────────────────────────────── + +" +`; + +exports[`Textarea - Rendering Tests > Absolute Positioned Box with Textarea > should render textarea in absolute positioned box with padding and borders correctly 1`] = ` +" + + │ │ + │ │ + │ Important Notification │ + │ │ + │ │ + │ This is a longer message that should wrap properly within + │ │ + │ │ + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render basic text content correctly 1`] = ` +" + + + Hello World + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render multiline text content correctly 1`] = ` +" + Line 1: Hello + Line 2: World + Line 3: Testing + Line 4: Multiline + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render placeholder when creating textarea with placeholder directly 1`] = ` +" + Enter text here... + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render placeholder when set programmatically after creation 1`] = ` +" + Type something... + + + + + + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render text with character wrapping correctly 1`] = ` +"This is a very +long text that +should wrap to +multiple lines +when wrap is en +abled + + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should render text with word wrapping and punctuation 1`] = ` +"Hello,World. +Test- +Example/ +Path with +various +punctuation +marks! + + + + + + + + + + + + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Textarea Content Snapshots > should resize correctly when typing return as first input with placeholder 1`] = ` +" + ┌────────────────────────────────────── + │ + │ + └────────────────────────────────────── + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Width/Height Setter Layout Tests > should not shrink box when height is set via setter in column layout with textarea 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Width/Height Setter Layout Tests > should not shrink box when minHeight is set via setter in column layout with textarea 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Width/Height Setter Layout Tests > should not shrink box when minWidth is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Width/Height Setter Layout Tests > should not shrink box when width is set from undefined via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Width/Height Setter Layout Tests > should not shrink box when width is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`Textarea - Rendering Tests > Wrapping > should render with tab indicator correctly 1`] = ` +"Line 1→ Tabbed +Line 2→ → Double tab + + + + + + + + + + + + + + + + + + + + + + +" +`; diff --git a/packages/core/src/renderables/__tests__/markdown-parser.test.ts b/packages/core/src/renderables/__tests__/markdown-parser.test.ts index d8c45e668..d98056395 100644 --- a/packages/core/src/renderables/__tests__/markdown-parser.test.ts +++ b/packages/core/src/renderables/__tests__/markdown-parser.test.ts @@ -46,7 +46,7 @@ test("handles empty content", () => { const state = parseMarkdownIncremental("", null) expect(state.content).toBe("") - expect(state.tokens).toEqual([]) + expect(Array.from(state.tokens)).toEqual([]) }) test("handles empty previous state", () => { diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 8e5c9946a..cf211f8ee 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -10,7 +10,8 @@ import { type WidthMethod, } from "./types.js" import { RGBA, parseColor, type ColorInput } from "./lib/RGBA.js" -import type { Pointer } from "bun:ffi" +import type { Pointer } from "./compat/ffi.js" +import { sleep } from "./compat/runtime.js" import { OptimizedBuffer } from "./buffer.js" import { resolveRenderLib, type RenderLib } from "./zig.js" import { TerminalConsole, type ConsoleOptions, capture } from "./console.js" @@ -659,7 +660,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private exitHandler: () => void = (() => { this.destroy() if (env.OTUI_DUMP_CAPTURES) { - Bun.sleep(100).then(() => { + sleep(100).then(() => { this.dumpOutputCache("=== CAPTURED OUTPUT ===\n") }) } diff --git a/packages/core/src/runtime-plugin.ts b/packages/core/src/runtime-plugin.ts index 9f5f62c35..ae47e7cdb 100644 --- a/packages/core/src/runtime-plugin.ts +++ b/packages/core/src/runtime-plugin.ts @@ -37,7 +37,6 @@ import { existsSync, readFileSync, realpathSync } from "node:fs" import { basename, dirname, isAbsolute, join } from "node:path" import { fileURLToPath } from "node:url" -import { type BunPlugin } from "bun" import * as coreRuntime from "./index.js" export type RuntimeModuleExports = Record @@ -60,6 +59,11 @@ export interface CreateRuntimePluginOptions { rewrite?: RuntimePluginRewriteOptions } +export interface BunPlugin { + name: string + setup(build: any): void | Promise +} + const CORE_RUNTIME_SPECIFIER = "@opentui/core" const CORE_TESTING_RUNTIME_SPECIFIER = "@opentui/core/testing" const RUNTIME_MODULE_PREFIX = "opentui:runtime-module:" @@ -451,7 +455,7 @@ export function createRuntimePlugin(input: CreateRuntimePluginOptions = {}): Bun // Register both the resolved path spelling and its canonical realpath so Bun // can reach the loader even if it reports the same file through a different alias. - build.onLoad({ filter: exactPathFilter([resolvedTargetPath, canonicalTargetPath]) }, async (args) => { + build.onLoad({ filter: exactPathFilter([resolvedTargetPath, canonicalTargetPath]) }, async (args: any) => { const loadedPath = normalizeSourcePath(args.path) if (loadedPath !== canonicalTargetPath) { return undefined @@ -466,7 +470,7 @@ export function createRuntimePlugin(input: CreateRuntimePluginOptions = {}): Bun throw new Error(`Unable to determine runtime loader for path: ${args.path}`) } - const contents = await Bun.file(loadedPath).text() + const contents = readFileSync(loadedPath, "utf8") const runtimeRewrittenContents = shouldRewriteRuntimeSpecifiers ? rewriteRuntimeSpecifiers(contents, runtimeModuleIdsBySpecifier) : contents @@ -564,7 +568,7 @@ export function createRuntimePlugin(input: CreateRuntimePluginOptions = {}): Bun build.onResolve({ filter: exactSpecifierFilter(specifier) }, () => ({ path: moduleId })) } - build.onResolve({ filter: /.*/ }, (args) => { + build.onResolve({ filter: /.*/ }, (args: any) => { if (runtimeModuleIdsBySpecifier.has(args.path) || args.path.startsWith(RUNTIME_MODULE_PREFIX)) { return undefined } diff --git a/packages/core/src/syntax-style.ts b/packages/core/src/syntax-style.ts index 3382aba76..611c60b91 100644 --- a/packages/core/src/syntax-style.ts +++ b/packages/core/src/syntax-style.ts @@ -1,6 +1,6 @@ import { RGBA, parseColor, type ColorInput } from "./lib/RGBA.js" import { resolveRenderLib, type RenderLib } from "./zig.js" -import { type Pointer } from "bun:ffi" +import { type Pointer } from "./compat/ffi.js" import { createTextAttributes } from "./utils.js" export interface StyleDefinition { diff --git a/packages/core/src/testing/mock-keys.test.ts b/packages/core/src/testing/mock-keys.test.ts index f183ada2f..55c6ef0fe 100644 --- a/packages/core/src/testing/mock-keys.test.ts +++ b/packages/core/src/testing/mock-keys.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect } from "bun:test" -import { createMockKeys, KeyCodes } from "./mock-keys.js" +import { describe, expect, test } from "bun:test" import { PassThrough } from "stream" +import { createMockKeys, KeyCodes } from "./mock-keys.js" class MockRenderer { public stdin: PassThrough @@ -385,7 +385,7 @@ describe("mock-keys", () => { }) test("pressTab with shift modifier parses as shift+tab", async () => { - const { parseKeypress } = await import("../lib/parse.keypress") + const { parseKeypress } = await import("../lib/parse.keypress.js") const mockRenderer = new MockRenderer() const mockKeys = createMockKeys(mockRenderer as any) @@ -975,7 +975,7 @@ describe("mock-keys", () => { describe("modifyOtherKeys Mode (CSI u variant)", () => { test("modifyOtherKeys sequences can be parsed by parseKeypress", async () => { - const { parseKeypress } = await import("../lib/parse.keypress") + const { parseKeypress } = await import("../lib/parse.keypress.js") // Test that our generated sequences can be parsed correctly const tests = [ diff --git a/packages/core/src/testing/test-recorder.test.ts b/packages/core/src/testing/test-recorder.test.ts index 7ffa3383d..c534ba785 100644 --- a/packages/core/src/testing/test-recorder.test.ts +++ b/packages/core/src/testing/test-recorder.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { sleep } from "../compat/runtime.js" import { createTestRenderer, type TestRenderer } from "./test-renderer.js" import { TestRecorder } from "./test-recorder.js" import { TextRenderable } from "../renderables/Text.js" @@ -42,7 +43,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Hello World" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) expect(recorder.recordedFrames.length).toBe(1) @@ -57,7 +58,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Test Content" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorder.recordedFrames expect(frames.length).toBe(1) @@ -71,7 +72,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Frame Metadata" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorder.recordedFrames expect(frames.length).toBe(1) @@ -86,7 +87,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Multiple Frames" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) await renderOnce() await renderOnce() @@ -105,13 +106,13 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Initial" }) renderer.root.add(text) - await Bun.sleep(10) + await sleep(10) text.content = "Changed" - await Bun.sleep(10) + await sleep(10) recorder.stop() - // NOTE: Should this fail, make sure the Bun.sleeps are in sync with maxFps of the renderer + // NOTE: Should this fail, make sure the sleeps are in sync with maxFps of the renderer const frame1 = recorder.recordedFrames[0].frame const frame2 = recorder.recordedFrames[1].frame @@ -123,7 +124,7 @@ describe("TestRecorder", () => { test("should not record when not started", async () => { const text = new TextRenderable(renderer, { content: "Not Recording" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) expect(recorder.recordedFrames.length).toBe(0) }) @@ -133,7 +134,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Stopped" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) expect(recorder.recordedFrames.length).toBe(1) @@ -147,7 +148,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Clear Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) await renderOnce() @@ -163,7 +164,7 @@ describe("TestRecorder", () => { recorder.rec() renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) recorder.stop() expect(recorder.recordedFrames.length).toBe(1) @@ -181,7 +182,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Duplicate Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) recorder.stop() @@ -193,7 +194,7 @@ describe("TestRecorder", () => { recorder.rec() renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) recorder.stop() recorder.clear() @@ -228,7 +229,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Copy Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames1 = recorder.recordedFrames const frames2 = recorder.recordedFrames @@ -256,7 +257,7 @@ describe("TestRecorder", () => { const text2 = new TextRenderable(renderer, { content: "Line 2" }) renderer.root.add(text1) renderer.root.add(text2) - await Bun.sleep(1) + await sleep(1) const frame = recorder.recordedFrames[0].frame expect(frame).toContain("Line 1") @@ -270,7 +271,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Rapid Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) for (let i = 0; i < 4; i++) { await renderOnce() @@ -287,7 +288,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Buffer Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorderWithFg.recordedFrames expect(frames.length).toBe(1) @@ -305,7 +306,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Buffer Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorderWithBg.recordedFrames expect(frames.length).toBe(1) @@ -323,7 +324,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Buffer Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorderWithAttrs.recordedFrames expect(frames.length).toBe(1) @@ -343,7 +344,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Buffer Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorderWithAll.recordedFrames expect(frames.length).toBe(1) @@ -360,7 +361,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "No Buffer Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorder.recordedFrames expect(frames.length).toBe(1) @@ -375,7 +376,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Copy Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) await renderOnce() @@ -400,7 +401,7 @@ describe("TestRecorder", () => { const text = new TextRenderable(renderer, { content: "Size Test" }) renderer.root.add(text) - await Bun.sleep(1) + await sleep(1) const frames = recorderWithAll.recordedFrames expect(frames.length).toBe(1) diff --git a/packages/core/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.nodejs.snap b/packages/core/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.nodejs.snap new file mode 100644 index 000000000..c415dcb0e --- /dev/null +++ b/packages/core/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.nodejs.snap @@ -0,0 +1,481 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Absolute Positioning - Snapshot Tests > Basic absolute positioning > absolute positioned box at bottom-right using right/bottom > absolute positioned box at bottom-right 1`] = ` +" + + + + + + + + + + + + + + + ┌─────────────┐ + │Bottom Right │ + │ │ + │ │ + └─────────────┘ +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Basic absolute positioning > absolute positioned box at top-left > absolute positioned box at top-left 1`] = ` +"┌─────────────┐ +│Top Left │ +│ │ +│ │ +└─────────────┘ + + + + + + + + + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Basic absolute positioning > absolute positioned box centered with left/top > absolute positioned box centered 1`] = ` +" + + + + + ┌──────────────────┐ + │Centered │ + │ │ + │ │ + │ │ + │ │ + │ │ + └──────────────────┘ + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Complex hierarchies > multiple nested relative and absolute layers > relative -> absolute -> relative -> absolute hierarchy 1`] = ` +"┌────────────────────────────────────┐ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────┐ │ │ │ +│ │ │ │Deep │ │ │ │ +│ │ │ └────────┘ │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +│ │ +└────────────────────────────────────┘ + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Complex hierarchies > relative parent with absolute child containing absolute grandchild > relative -> absolute -> absolute hierarchy 1`] = ` +" + ┌─────────────────────────────────┐ + │ │ + │ ┌──────────────────────────┐ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ┌──────────┐ │ │ + │ │ │Grand │ │ │ + │ │ │ │ │ │ + │ │ └──────────┘ │ │ + │ │ │ │ + │ └──────────────────────────┘ │ + │ │ + └─────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute child fills parent completely > absolute child fills parent with inset 0 1`] = ` +" + + + ┌────────────────────────────┐ + │╔══════════════════════════╗│ + │║Full ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │╚══════════════════════════╝│ + └────────────────────────────┘ + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute child with conflicting insets (left and right without explicit width) > absolute child with left and right insets (no explicit width) 1`] = ` +" + + ┌────────────────────────────────┐ + │ │ + │ │ + │ ┌──────────────────────────┐ │ + │ │Stretch │ │ + │ │ │ │ + │ │ │ │ + │ └──────────────────────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute child with conflicting insets (top and bottom without explicit height) > absolute child with top and bottom insets (no explicit height) 1`] = ` +" + ┌────────────────────────────┐ + │ │ + │ ┌─────────────┐ │ + │ │VStretch │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ └─────────────┘ │ + │ │ + └────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute positioned box extending beyond viewport > absolute box extending beyond viewport 1`] = ` +" + + + + + + + + + + + + + + + ┌───────── + │Overflow + │ + │ + │ +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute positioned box with negative coordinates (partially off-screen) > absolute box with negative coordinates 1`] = ` +" │ + │ + │ + │ + │ +──────────────┘ + + + + + + + + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute positioned box with percentage height inside absolute parent > absolute child with percentage height 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ ┌─────────────┐ │ + │ │50% H │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ └─────────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────┘ + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Edge cases > absolute positioned box with percentage width inside absolute parent > absolute child with percentage width 1`] = ` +" + + ┌──────────────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌─────────────────┐ │ + │ │50% │ │ + │ │ │ │ + │ └─────────────────┘ │ + │ │ + └──────────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Mixed positioning > absolute child inside relative parent > absolute child inside relative parent 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌──────────┐ │ + │ │Absolute │ │ + │ │ │ │ + │ └──────────┘ │ + │ │ + └────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Mixed positioning > sibling absolute elements at same level > sibling absolute elements overlapping 1`] = ` +"┌─────────────┐ +│Box 1 │ +│ │ +│ │ +│ ┌─────────────┐ +└───────────│Box 2 │ + │ │ + │ │ + │ ┌─────────────┐ + └───────────│Box 3 │ + │ │ + │ │ + │ │ + └─────────────┘ + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Nested absolute positioning > absolute child at bottom:0 inside absolute parent (issue #406 fix) > nested absolute - child at bottom:0 of parent 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌─────────────┐ │ + │ │At Bottom │ │ + │ └─────────────┘ │ + └────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Nested absolute positioning > absolute child at bottom-right corner inside absolute parent > nested absolute - child at bottom-right corner 1`] = ` +" + ┌────────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌────────────┐ │ + │ │Corner │ │ + │ │ │ │ + │ └────────────┘ │ + │ │ + └────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Nested absolute positioning > absolute child at right:0 inside absolute parent > nested absolute - child at right:0 of parent 1`] = ` +" + + ┌─────────────────────────────────┐ + │ │ + │ ┌──────────┐│ + │ │At Right ││ + │ │ ││ + │ └──────────┘│ + │ │ + │ │ + │ │ + │ │ + │ │ + └─────────────────────────────────┘ + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Nested absolute positioning > absolute child inside absolute parent - basic > nested absolute - child inside parent at left/top 1`] = ` +" + + + ┌────────────────────────────┐ + │ │ + │ ┌──────────┐ │ + │ │Nested │ │ + │ │ │ │ + │ └──────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────┘ + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Nested absolute positioning > multiple absolute children inside absolute parent at different positions > nested absolute - four corners inside parent 1`] = ` +" + ┌──────────────────────────────────┐ + │ │ + │ ┌────────┐ ┌────────┐ │ + │ │TL │ │TR │ │ + │ └────────┘ └────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌────────┐ ┌────────┐ │ + │ │BL │ │BR │ │ + │ └────────┘ └────────┘ │ + │ │ + └──────────────────────────────────┘ + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests > Three-level nesting > deeply nested absolute positioning - grandchild at bottom > three-level nested absolute - grandchild at bottom 1`] = ` +" + ┌────────────────────────────────────┐ + │ │ + │ │ + │ ┌──────────────────────────────┐ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ┌─────────────┐ │ │ + │ │ │Deep │ │ │ + │ │ └─────────────┘ │ │ + │ │ │ │ + │ └──────────────────────────────┘ │ + │ │ + │ │ + └────────────────────────────────────┘ + +" +`; diff --git a/packages/core/src/tests/__snapshots__/renderable.snapshot.test.ts.nodejs.snap b/packages/core/src/tests/__snapshots__/renderable.snapshot.test.ts.nodejs.snap new file mode 100644 index 000000000..711c8b5d5 --- /dev/null +++ b/packages/core/src/tests/__snapshots__/renderable.snapshot.test.ts.nodejs.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Renderable - insertBefore > reproduces insertBefore behavior with state change after timeout > insertBefore initial state 1`] = ` +"banana +apple +pear + + +" +`; + +exports[`Renderable - insertBefore > reproduces insertBefore behavior with state change after timeout > insertBefore reordered state 1`] = ` +"banana +pear +apple + + +" +`; diff --git a/packages/core/src/tests/__snapshots__/scrollbox.test.ts.nodejs.snap b/packages/core/src/tests/__snapshots__/scrollbox.test.ts.nodejs.snap new file mode 100644 index 000000000..1fd204604 --- /dev/null +++ b/packages/core/src/tests/__snapshots__/scrollbox.test.ts.nodejs.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ScrollBoxRenderable - Content Visibility > scrolls CodeRenderable with LineNumberRenderable using mouse wheel 1`] = ` +" 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 ▄ + + + + + + + + + + + + + + +" +`; diff --git a/packages/core/src/tests/destroy-on-exit.test.ts b/packages/core/src/tests/destroy-on-exit.test.ts index 5f66b9d97..4ca5bd0f8 100644 --- a/packages/core/src/tests/destroy-on-exit.test.ts +++ b/packages/core/src/tests/destroy-on-exit.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "bun:test" +import { spawnSync } from "../compat/testHelpers.js" import { join } from "node:path" -const fixturePath = join(import.meta.dir, "destroy-on-exit.fixture.ts") +const fixturePath = join(import.meta.dirname, "destroy-on-exit.fixture.ts") +const supportedDescribe = process.versions.bun ? describe : describe.skip const runFixture = (code: number, mode: "idle" | "during-render" = "idle") => { - const result = Bun.spawnSync([process.execPath, fixturePath, code.toString(), mode], { - cwd: join(import.meta.dir, ".."), + const result = spawnSync([process.execPath, fixturePath, code.toString(), mode], { + cwd: join(import.meta.dirname, ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -16,7 +18,7 @@ const runFixture = (code: number, mode: "idle" | "during-render" = "idle") => { return { result, stdout } } -describe("destroy on process exit", () => { +supportedDescribe("destroy on process exit", () => { it("it should let applications restore terminal state in an exit handler", () => { const { result, stdout } = runFixture(0) diff --git a/packages/core/src/tests/renderable.test.ts b/packages/core/src/tests/renderable.test.ts index 39454c99a..6fff14df2 100644 --- a/packages/core/src/tests/renderable.test.ts +++ b/packages/core/src/tests/renderable.test.ts @@ -118,8 +118,8 @@ describe("Renderable", () => { expect(renderable.liveCount).toBe(0) }) - test("isRenderable", () => { - const { isRenderable } = require("../Renderable") + test("isRenderable", async () => { + const { isRenderable } = await import("../Renderable.js") const renderable = new TestBaseRenderable({}) expect(isRenderable(renderable)).toBe(true) expect(isRenderable({})).toBe(false) diff --git a/packages/core/src/tests/renderer.clock.test.ts b/packages/core/src/tests/renderer.clock.test.ts index 4e184cb26..fcf27b937 100644 --- a/packages/core/src/tests/renderer.clock.test.ts +++ b/packages/core/src/tests/renderer.clock.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test" +import { sleep } from "../compat/runtime.js" import { SystemClock } from "../lib/clock.js" import { createTestRenderer, type TestRenderer } from "../testing/test-renderer.js" import { ManualClock } from "../testing/manual-clock.js" @@ -76,7 +77,7 @@ test("requestRender() uses SystemClock by default when no clock is injected", as } defaultRenderer.requestRender() - await Bun.sleep(20) + await sleep(20) expect(renderCalled).toBe(true) } finally { diff --git a/packages/core/src/tests/renderer.control.test.ts b/packages/core/src/tests/renderer.control.test.ts index 7d71f4fde..92d882dc5 100644 --- a/packages/core/src/tests/renderer.control.test.ts +++ b/packages/core/src/tests/renderer.control.test.ts @@ -1,4 +1,5 @@ import { test, expect, beforeEach, afterEach } from "bun:test" +import { sleep } from "../compat/runtime.js" import { createTestRenderer, type TestRenderer, type MockInput, type MockMouse } from "../testing/test-renderer.js" import { RendererControlState } from "../renderer.js" import { Renderable } from "../Renderable.js" @@ -149,7 +150,7 @@ test("requestRender() does not trigger when renderer is suspended", async () => test("requestRender() does trigger when renderer is paused", async () => { renderer.start() - await Bun.sleep(20) + await sleep(20) renderer.pause() let renderCalled = false @@ -162,7 +163,7 @@ test("requestRender() does trigger when renderer is paused", async () => { } renderer.requestRender() - await Bun.sleep(20) + await sleep(20) expect(renderCalled).toBe(true) diff --git a/packages/core/src/tests/renderer.mouse.test.ts b/packages/core/src/tests/renderer.mouse.test.ts index 79637df3d..64c935249 100644 --- a/packages/core/src/tests/renderer.mouse.test.ts +++ b/packages/core/src/tests/renderer.mouse.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" +import { sleep } from "../compat/runtime.js" import { createTestRenderer, MouseButtons, type MockMouse, type TestRenderer } from "../testing.js" import { Renderable, type RenderableOptions } from "../Renderable.js" import type { MouseEvent } from "../renderer.js" @@ -55,7 +56,7 @@ describe("renderer handleMouseData", () => { } renderer.stdin.emit("data", Buffer.from("x")) - await Bun.sleep(10) + await sleep(10) expect(sequences).toContain("x") expect(mouseDown).toBe(false) @@ -73,7 +74,7 @@ describe("renderer handleMouseData", () => { }) renderer.stdin.emit("data", Buffer.from("x")) - await Bun.sleep(10) + await sleep(10) expect(sequences).toContain("x") } finally { @@ -1264,7 +1265,7 @@ describe("renderer handleMouseData split height", () => { const renderOffset = baseHeight - splitHeight const beforeSequences = sequences.length await mockMouse.click(1, Math.max(0, renderOffset - 1)) - await Bun.sleep(10) + await sleep(10) expect(sequences.length).toBeGreaterThan(beforeSequences) } finally { diff --git a/packages/core/src/tests/runtime-plugin-support.test.ts b/packages/core/src/tests/runtime-plugin-support.test.ts index 2d935ae8c..db9f9f0b8 100644 --- a/packages/core/src/tests/runtime-plugin-support.test.ts +++ b/packages/core/src/tests/runtime-plugin-support.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "bun:test" +import { spawnSync } from "../compat/testHelpers.js" import { join } from "node:path" -describe("runtime plugin support", () => { +// Fixtures require `import { plugin } from "bun"` — no Node.js equivalent. +const _describe = process.versions.bun ? describe : describe.skip + +_describe("runtime plugin support", () => { it("installs exactly once via drop-in module", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-support.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + const fixturePath = join(import.meta.dirname, "runtime-plugin-support.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, diff --git a/packages/core/src/tests/runtime-plugin.test.ts b/packages/core/src/tests/runtime-plugin.test.ts index 70b964a71..0e412e409 100644 --- a/packages/core/src/tests/runtime-plugin.test.ts +++ b/packages/core/src/tests/runtime-plugin.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test" +import { spawnSync } from "../compat/testHelpers.js" import { join } from "node:path" import * as coreRuntime from "../index.js" import { createRuntimePlugin, runtimeModuleIdForSpecifier } from "../runtime-plugin.js" @@ -193,10 +194,14 @@ describe("runtime plugin", () => { ) }) - it("resolves runtime modules end-to-end in a subprocess", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + // Subprocess fixture tests require `import { plugin } from "bun"` which has + // no Node.js equivalent. + const bunIt = process.versions.bun ? it : it.skip + + bunIt("resolves runtime modules end-to-end in a subprocess", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -208,10 +213,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("core=core-value;coreTesting=true;sync=sync-value;async=async-value") }) - it("resolves bare imports from external runtime roots", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-resolve-roots.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("resolves bare imports from external runtime roots", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-resolve-roots.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -223,10 +228,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("marker=resolved-from-external-root") }) - it("rewrites runtime specifiers in node_modules modules by default", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-runtime-specifier.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("rewrites runtime specifiers in node_modules modules by default", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-runtime-specifier.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -238,10 +243,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("marker=resolved-from-node-modules-runtime-specifier") }) - it("rewrites runtime specifiers in node_modules .mjs modules", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-mjs.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("rewrites runtime specifiers in node_modules .mjs modules", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-mjs.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -253,10 +258,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("marker=resolved-from-node-modules-mjs") }) - it("rewrites runtime specifiers across node_modules ESM cycles", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-cycle.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("rewrites runtime specifiers across node_modules ESM cycles", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-cycle.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -270,10 +275,10 @@ describe("runtime plugin", () => { ) }) - it("does not keep stale node_modules package type across plugin instances", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-package-type-cache.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("does not keep stale node_modules package type across plugin instances", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-package-type-cache.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -285,10 +290,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("marker=resolved-after-package-type-change") }) - it("rewrites bare imports for scoped node_modules package siblings when enabled", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("rewrites bare imports for scoped node_modules package siblings when enabled", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -302,10 +307,10 @@ describe("runtime plugin", () => { ) }) - it("does not rewrite non-runtime bare imports in node_modules modules by default", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-node-modules-no-bare-rewrite.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("does not rewrite non-runtime bare imports in node_modules modules by default", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-node-modules-no-bare-rewrite.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -317,10 +322,10 @@ describe("runtime plugin", () => { expect(stdout).toContain("errorContainsMissingBareDependency=true") }) - it("rewrites runtime specifiers when Bun canonicalizes a symlinked import path", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-path-alias.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + bunIt("rewrites runtime specifiers when Bun canonicalizes a symlinked import path", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-path-alias.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, @@ -333,14 +338,14 @@ describe("runtime plugin", () => { expect(stdout).toContain("marker=resolved-from-path-alias") }) - it("rewrites runtime specifiers for file URL imports on Windows", () => { + bunIt("rewrites runtime specifiers for file URL imports on Windows", () => { if (process.platform !== "win32") { return } - const fixturePath = join(import.meta.dir, "runtime-plugin-windows-file-url.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, "..", ".."), + const fixturePath = join(import.meta.dirname, "runtime-plugin-windows-file-url.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, "..", ".."), stdout: "pipe", stderr: "pipe", env: process.env, diff --git a/packages/core/src/text-buffer-view.ts b/packages/core/src/text-buffer-view.ts index f671f02c4..528bae757 100644 --- a/packages/core/src/text-buffer-view.ts +++ b/packages/core/src/text-buffer-view.ts @@ -1,6 +1,6 @@ import { RGBA } from "./lib/RGBA.js" import { resolveRenderLib, type LineInfo, type RenderLib } from "./zig.js" -import { type Pointer } from "bun:ffi" +import { type Pointer } from "./compat/ffi.js" import type { TextBuffer } from "./text-buffer.js" export class TextBufferView { diff --git a/packages/core/src/text-buffer.ts b/packages/core/src/text-buffer.ts index a75fdd634..c6d44de05 100644 --- a/packages/core/src/text-buffer.ts +++ b/packages/core/src/text-buffer.ts @@ -1,7 +1,7 @@ import type { StyledText } from "./lib/styled-text.js" import { RGBA } from "./lib/RGBA.js" import { resolveRenderLib, type LineInfo, type RenderLib } from "./zig.js" -import { type Pointer } from "bun:ffi" +import { type Pointer } from "./compat/ffi.js" import { type WidthMethod, type Highlight } from "./types.js" import type { SyntaxStyle } from "./syntax-style.js" diff --git a/packages/core/src/zig-structs.ts b/packages/core/src/zig-structs.ts index 36bce84a4..015978a1c 100644 --- a/packages/core/src/zig-structs.ts +++ b/packages/core/src/zig-structs.ts @@ -1,9 +1,10 @@ -import { defineStruct, defineEnum } from "bun-ffi-structs" -import { ptr, toArrayBuffer, type Pointer } from "bun:ffi" +import { defineEnum, defineStruct } from "./compat/bun-ffi-structs.js" +import { ptr, toArrayBuffer, type Pointer } from "./compat/ffi.js" import { RGBA } from "./lib/RGBA.js" const rgbaPackTransform = (rgba?: RGBA) => (rgba ? ptr(rgba.buffer) : null) -const rgbaUnpackTransform = (ptr?: Pointer) => (ptr ? RGBA.fromArray(new Float32Array(toArrayBuffer(ptr))) : undefined) +const rgbaUnpackTransform = (ptr?: Pointer) => + ptr ? RGBA.fromArray(new Float32Array(toArrayBuffer(ptr, 0, RGBA.BYTE_LENGTH))) : undefined type StyledChunkInput = { text: string diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 90f79ac1d..f33e82242 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -1,50 +1,50 @@ -import { dlopen, toArrayBuffer, JSCallback, ptr, type Pointer } from "bun:ffi" -import { existsSync, writeFileSync } from "fs" +import { JSCallback, dlopen, ptr, toArrayBuffer, type Pointer } from "./compat/ffi.js" import { EventEmitter } from "events" +import { existsSync, writeFileSync } from "fs" import { type CursorStyle, type CursorStyleOptions, - type TargetChannel, type DebugOverlayCorner, - type WidthMethod, type Highlight, type LineInfo, - type MousePointerStyle, + type TargetChannel, + type WidthMethod, } from "./types.js" -export type { LineInfo, AllocatorStats, BuildOptions } +export type { AllocatorStats, BuildOptions, LineInfo } -import { RGBA } from "./lib/RGBA.js" import { OptimizedBuffer } from "./buffer.js" -import { TextBuffer } from "./text-buffer.js" +import { isBunfsPath } from "./lib/bunfs.js" import { env, registerEnvVar } from "./lib/env.js" +import { RGBA } from "./lib/RGBA.js" +import { writeFile } from "./compat/runtime.js" +import { TextBuffer } from "./text-buffer.js" +import type { + AllocatorStats, + BuildOptions, + NativeSpanFeedOptions, + NativeSpanFeedStats, + ReserveInfo, +} from "./zig-structs.js" import { - StyledChunkStruct, - HighlightStruct, - LogicalCursorStruct, - VisualCursorStruct, - TerminalCapabilitiesStruct, - EncodedCharStruct, - LineInfoStruct, - MeasureResultStruct, + AllocatorStatsStruct, + BuildOptionsStruct, CursorStateStruct, CursorStyleOptionsStruct, + EncodedCharStruct, GridDrawOptionsStruct, + HighlightStruct, + LineInfoStruct, + LogicalCursorStruct, + MeasureResultStruct, NativeSpanFeedOptionsStruct, NativeSpanFeedStatsStruct, ReserveInfoStruct, - BuildOptionsStruct, - AllocatorStatsStruct, -} from "./zig-structs.js" -import type { - NativeSpanFeedOptions, - NativeSpanFeedStats, - ReserveInfo, - BuildOptions, - AllocatorStats, + StyledChunkStruct, + TerminalCapabilitiesStruct, + VisualCursorStruct, } from "./zig-structs.js" -import { isBunfsPath } from "./lib/bunfs.js" -const module = await import(`@opentui/core-${process.platform}-${process.arch}/index.ts`) +const module = await import(`@opentui/core-${process.platform}-${process.arch}/index.js`) let targetLibPath = module.default if (isBunfsPath(targetLibPath)) { @@ -52,7 +52,9 @@ if (isBunfsPath(targetLibPath)) { } if (!existsSync(targetLibPath)) { - throw new Error(`opentui is not supported on the current platform: ${process.platform}-${process.arch}`) + throw new Error( + `opentui is not supported on the current platform: ${process.platform}-${process.arch}: not found: ${targetLibPath}`, + ) } registerEnvVar({ @@ -1328,7 +1330,9 @@ function convertToDebugSymbols>(symbols: T): T { const now = new Date() const timestamp = now.toISOString().replace(/[:.]/g, "-").replace(/T/, "_").split("Z")[0] const traceFilePath = `ffi_otui_trace_${timestamp}.log` - Bun.write(traceFilePath, output) + void writeFile(traceFilePath, output).catch((error) => { + console.error("Failed to write FFI trace file:", error) + }) } catch (e) { console.error("Failed to write FFI trace file:", e) } @@ -3871,7 +3875,7 @@ export function resolveRenderLib(): RenderLib { opentuiLib = new FFIRenderLib(opentuiLibPath) } catch (error) { throw new Error( - `Failed to initialize OpenTUI render library: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to initialize OpenTUI render library: ${error instanceof Error ? error.stack : "Unknown error"}`, ) } } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 000000000..52eef3346 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,23 @@ +/** + * Vitest is used to run tests under Node.js. + * Tests import `bun:test`, which is aliased to the Vitest adapter here. + */ + +import { basename, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + "bun:test": fileURLToPath(new URL("./src/compat/test.ts", import.meta.url)), + }, + external: true, + }, + test: { + environment: "node", + resolveSnapshotPath: (testPath, ext) => + join(dirname(testPath), "__snapshots__", `${basename(testPath)}.nodejs${ext}`), + root: "src", + }, +}) diff --git a/packages/react/bunfig.toml b/packages/react/bunfig.toml new file mode 100644 index 000000000..a647966f0 --- /dev/null +++ b/packages/react/bunfig.toml @@ -0,0 +1,2 @@ +[test] +pathIgnorePatterns = ["dist-test/**"] diff --git a/packages/react/dist-test/bun/index.test.tsx b/packages/react/dist-test/bun/index.test.tsx new file mode 100644 index 000000000..0e0741c04 --- /dev/null +++ b/packages/react/dist-test/bun/index.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { createRoot } from "@opentui/react" +import { testRender } from "@opentui/react/test-utils" +import { act, useState, type ReactNode } from "react" + +describe("@opentui/react dist test (Bun)", () => { + test("imports react public entrypoints", async () => { + const react = await import("@opentui/react") + const testUtils = await import("@opentui/react/test-utils") + + expect(typeof react.createRoot).toBe("function") + expect(typeof react.useKeyboard).toBe("function") + expect(typeof react.useRenderer).toBe("function") + expect(typeof testUtils.testRender).toBe("function") + }) + + test("renders simple text via testRender", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender(Hello from React Bun dist test, { + width: 40, + height: 4, + }) + + try { + await renderOnce() + expect(captureCharFrame()).toMatch(/Hello from React Bun dist test/) + } finally { + renderer.destroy() + } + }) + + test("renders nested box layout", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Line A + Line B + , + { width: 30, height: 6 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + expect(frame).toMatch(/Line A/) + expect(frame).toMatch(/Line B/) + } finally { + renderer.destroy() + } + }) + + test("renders a stateful component", async () => { + function Counter({ initial }: { initial: number }): ReactNode { + const [count] = useState(initial) + return {`Count: ${count}`} + } + + const { renderer, renderOnce, captureCharFrame } = await testRender(, { + width: 20, + height: 4, + }) + + try { + await renderOnce() + expect(captureCharFrame()).toMatch(/Count: 42/) + } finally { + renderer.destroy() + } + }) + + test("renders a box with border", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Boxed content + , + { width: 30, height: 8 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + expect(frame).toMatch(/Greetings/) + expect(frame).toMatch(/Boxed content/) + } finally { + renderer.destroy() + } + }) + + test("uses createRoot directly with createTestRenderer", async () => { + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 30, + height: 4, + }) + + // @ts-expect-error - required for React act() to work in test environment + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(renderer) + + try { + act(() => { + root.render(Direct root render) + }) + await renderOnce() + expect(captureCharFrame()).toMatch(/Direct root render/) + } finally { + act(() => { + root.unmount() + }) + renderer.destroy() + // @ts-expect-error + globalThis.IS_REACT_ACT_ENVIRONMENT = false + } + }) +}) diff --git a/packages/react/dist-test/bun/package.json b/packages/react/dist-test/bun/package.json new file mode 100644 index 000000000..1ff91ba00 --- /dev/null +++ b/packages/react/dist-test/bun/package.json @@ -0,0 +1,17 @@ +{ + "name": "@opentui/react-dist-test-bun", + "private": true, + "type": "module", + "engines": { + "bun": ">=1.3.0" + }, + "scripts": { + "test": "bun test index.test.tsx" + }, + "dependencies": { + "@opentui/core": "*", + "@opentui/react": "*", + "react": ">=19.0.0", + "@types/react": "^19.0.0" + } +} diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/index.tsx b/packages/react/dist-test/nodejs-typescript-vanilla-react18/index.tsx new file mode 100644 index 000000000..b3edc734d --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/index.tsx @@ -0,0 +1,155 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { createCliRenderer } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { createRoot } from "@opentui/react" +import { testRender } from "@opentui/react/test-utils" +import { useState, useEffect, type ReactNode } from "react" +// In React 18, act is exported from react-dom/test-utils +import { act } from "react-dom/test-utils" + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("@opentui/react dist test (Node.js + TypeScript, vanilla React 18)", () => { + it("imports react public entrypoints", async () => { + const react = await import("@opentui/react") + const testUtils = await import("@opentui/react/test-utils") + + assert.equal(typeof react.createRoot, "function") + assert.equal(typeof react.useKeyboard, "function") + assert.equal(typeof react.useRenderer, "function") + assert.equal(typeof testUtils.testRender, "function") + }) + + it("renders simple text via testRender", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender(Hello from React 18 dist test, { + width: 40, + height: 4, + }) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Hello from React 18 dist test/) + } finally { + renderer.destroy() + } + }) + + it("renders nested box layout", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Line A + Line B + , + { width: 30, height: 6 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Line A/) + assert.match(frame, /Line B/) + } finally { + renderer.destroy() + } + }) + + it("renders a stateful component", async () => { + function Counter({ initial }: { initial: number }): ReactNode { + const [count] = useState(initial) + return {`Count: ${count}`} + } + + const { renderer, renderOnce, captureCharFrame } = await testRender(, { + width: 20, + height: 4, + }) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Count: 42/) + } finally { + renderer.destroy() + } + }) + + it("renders a box with border", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Boxed content + , + { width: 30, height: 8 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Greetings/) + assert.match(frame, /Boxed content/) + } finally { + renderer.destroy() + } + }) + + it("uses createRoot directly with createTestRenderer", async () => { + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 30, + height: 4, + }) + + // @ts-expect-error - required for React act() to work in test environment + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(renderer) + + try { + act(() => { + root.render(Direct root render) + }) + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Direct root render/) + } finally { + act(() => { + root.unmount() + }) + renderer.destroy() + // @ts-expect-error + globalThis.IS_REACT_ACT_ENVIRONMENT = false + } + }) +}) + +// --------------------------------------------------------------------------- +// Interactive app — runs when executed directly: node dist/index.js +// --------------------------------------------------------------------------- + +function InteractiveApp(): ReactNode { + const [counter, setCounter] = useState(0) + const [dots, setDots] = useState("") + + useEffect(() => { + const interval = setInterval(() => { + setCounter((c) => c + 1) + setDots((d) => (d.length >= 3 ? "" : d + ".")) + }, 500) + return () => clearInterval(interval) + }, []) + + return ( + + + {`Counter: ${counter}${dots}`} + + Press Ctrl+C to exit + + ) +} + +if (process.env.NODE_TEST_CONTEXT === undefined && import.meta.main) { + const renderer = await createCliRenderer() + createRoot(renderer).render() +} diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/loader.mjs b/packages/react/dist-test/nodejs-typescript-vanilla-react18/loader.mjs new file mode 100644 index 000000000..b15f6e718 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/loader.mjs @@ -0,0 +1,21 @@ +// Node.js module resolution hook that redirects react-reconciler imports +// to local ESM shims, bridging API differences between 0.29 (React 18) and 0.32 (React 19). +import { pathToFileURL } from "node:url" +import { resolve as pathResolve } from "node:path" +import { fileURLToPath } from "node:url" + +const dir = fileURLToPath(new URL(".", import.meta.url)) + +const shims = { + "react-reconciler/constants.js": pathToFileURL(pathResolve(dir, "react-reconciler-constants-shim.mjs")).href, + "react-reconciler/constants": pathToFileURL(pathResolve(dir, "react-reconciler-constants-shim.mjs")).href, + "react-reconciler": pathToFileURL(pathResolve(dir, "react-reconciler-shim.mjs")).href, +} + +export function resolve(specifier, context, nextResolve) { + const shimUrl = shims[specifier] + if (shimUrl) { + return { url: shimUrl, shortCircuit: true } + } + return nextResolve(specifier, context) +} diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/opentui-jsx.d.ts b/packages/react/dist-test/nodejs-typescript-vanilla-react18/opentui-jsx.d.ts new file mode 100644 index 000000000..f7a57024a --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/opentui-jsx.d.ts @@ -0,0 +1,44 @@ +import type { + BoxProps, + TextProps, + SpanProps, + CodeProps, + DiffProps, + MarkdownProps, + InputProps, + TextareaProps, + SelectProps, + ScrollBoxProps, + AsciiFontProps, + TabSelectProps, + LineNumberProps, + LineBreakProps, + LinkProps, +} from "@opentui/react" + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + box: BoxProps + text: TextProps + span: SpanProps + code: CodeProps + diff: DiffProps + markdown: MarkdownProps + input: InputProps + textarea: TextareaProps + select: SelectProps + scrollbox: ScrollBoxProps + "ascii-font": AsciiFontProps + "tab-select": TabSelectProps + "line-number": LineNumberProps + b: SpanProps + i: SpanProps + u: SpanProps + strong: SpanProps + em: SpanProps + br: LineBreakProps + a: LinkProps + } + } +} diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/package.json b/packages/react/dist-test/nodejs-typescript-vanilla-react18/package.json new file mode 100644 index 000000000..22e7f0159 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/package.json @@ -0,0 +1,25 @@ +{ + "name": "@opentui/react-dist-test-nodejs-typescript-vanilla-react18", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "npx tsgo --project tsconfig.json", + "test": "node --import ./register-loader.mjs --test dist/index.js" + }, + "dependencies": { + "@opentui/core": "*", + "@opentui/react": "*", + "@typescript/native-preview": "latest", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@types/node": "^24.0.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "overrides": { + "react-reconciler": "0.29.2" + } +} diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-constants-shim.mjs b/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-constants-shim.mjs new file mode 100644 index 000000000..f3ac174d8 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-constants-shim.mjs @@ -0,0 +1,16 @@ +// ESM re-export of react-reconciler/constants for React 18's CJS build, +// which Node.js cannot statically analyze for named exports. +// Also polyfills NoEventPriority (added in react-reconciler 0.32 / React 19). +import { createRequire } from "node:module" +const require = createRequire(import.meta.url) +const constants = require("react-reconciler/constants") +export const { + ConcurrentRoot, + ContinuousEventPriority, + DefaultEventPriority, + DiscreteEventPriority, + IdleEventPriority, + LegacyRoot, +} = constants +// NoEventPriority was added in react-reconciler 0.32; in 0.29 it's 0. +export const NoEventPriority = constants.NoEventPriority ?? 0 diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-shim.mjs b/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-shim.mjs new file mode 100644 index 000000000..c3f268034 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/react-reconciler-shim.mjs @@ -0,0 +1,39 @@ +// ESM wrapper for react-reconciler that patches API differences between +// react-reconciler 0.29 (React 18) and 0.32 (React 19). +import { createRequire } from "node:module" +const require = createRequire(import.meta.url) +const Reconciler = require("react-reconciler") +const constants = require("react-reconciler/constants") + +function PatchedReconciler(hostConfig) { + // react-reconciler 0.29 requires getCurrentEventPriority in the host config, + // but 0.32 removed it. Provide a default that returns DefaultEventPriority. + const patchedConfig = { ...hostConfig } + if (!patchedConfig.getCurrentEventPriority) { + patchedConfig.getCurrentEventPriority = () => constants.DefaultEventPriority + } + + const instance = Reconciler(patchedConfig) + + // 0.32 added flushSyncWork; 0.29 has flushSync instead. + if (!instance.flushSyncWork && instance.flushSync) { + instance.flushSyncWork = instance.flushSync + } + + // 0.32 allows injectIntoDevTools() with no args; 0.29 requires a config object. + const originalInject = instance.injectIntoDevTools + instance.injectIntoDevTools = function (config) { + if (!config) { + return originalInject.call(this, { + bundleType: 0, + version: "18.3.1", + rendererPackageName: "@opentui/react", + }) + } + return originalInject.call(this, config) + } + + return instance +} + +export default PatchedReconciler diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/register-loader.mjs b/packages/react/dist-test/nodejs-typescript-vanilla-react18/register-loader.mjs new file mode 100644 index 000000000..4abfc2c2c --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/register-loader.mjs @@ -0,0 +1,2 @@ +import { register } from "node:module" +register("./loader.mjs", import.meta.url) diff --git a/packages/react/dist-test/nodejs-typescript-vanilla-react18/tsconfig.json b/packages/react/dist-test/nodejs-typescript-vanilla-react18/tsconfig.json new file mode 100644 index 000000000..afd750ff9 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript-vanilla-react18/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["index.tsx", "opentui-jsx.d.ts"] +} diff --git a/packages/react/dist-test/nodejs-typescript/index.tsx b/packages/react/dist-test/nodejs-typescript/index.tsx new file mode 100644 index 000000000..99b79aff3 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript/index.tsx @@ -0,0 +1,153 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { createCliRenderer } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { createRoot } from "@opentui/react" +import { testRender } from "@opentui/react/test-utils" +import { act, useState, useEffect, type ReactNode } from "react" + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("@opentui/react dist test (Node.js + TypeScript)", () => { + it("imports react public entrypoints", async () => { + const react = await import("@opentui/react") + const testUtils = await import("@opentui/react/test-utils") + + assert.equal(typeof react.createRoot, "function") + assert.equal(typeof react.useKeyboard, "function") + assert.equal(typeof react.useRenderer, "function") + assert.equal(typeof testUtils.testRender, "function") + }) + + it("renders simple text via testRender", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender(Hello from React dist test, { + width: 40, + height: 4, + }) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Hello from React dist test/) + } finally { + renderer.destroy() + } + }) + + it("renders nested box layout", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Line A + Line B + , + { width: 30, height: 6 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Line A/) + assert.match(frame, /Line B/) + } finally { + renderer.destroy() + } + }) + + it("renders a stateful component", async () => { + function Counter({ initial }: { initial: number }): ReactNode { + const [count] = useState(initial) + return {`Count: ${count}`} + } + + const { renderer, renderOnce, captureCharFrame } = await testRender(, { + width: 20, + height: 4, + }) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Count: 42/) + } finally { + renderer.destroy() + } + }) + + it("renders a box with border", async () => { + const { renderer, renderOnce, captureCharFrame } = await testRender( + + Boxed content + , + { width: 30, height: 8 }, + ) + + try { + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Greetings/) + assert.match(frame, /Boxed content/) + } finally { + renderer.destroy() + } + }) + + it("uses createRoot directly with createTestRenderer", async () => { + const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ + width: 30, + height: 4, + }) + + // @ts-expect-error - required for React act() to work in test environment + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(renderer) + + try { + act(() => { + root.render(Direct root render) + }) + await renderOnce() + const frame = captureCharFrame() + assert.match(frame, /Direct root render/) + } finally { + act(() => { + root.unmount() + }) + renderer.destroy() + // @ts-expect-error + globalThis.IS_REACT_ACT_ENVIRONMENT = false + } + }) +}) + +// --------------------------------------------------------------------------- +// Interactive app — runs when executed directly: node dist/index.js +// --------------------------------------------------------------------------- + +function InteractiveApp(): ReactNode { + const [counter, setCounter] = useState(0) + const [dots, setDots] = useState("") + + useEffect(() => { + const interval = setInterval(() => { + setCounter((c) => c + 1) + setDots((d) => (d.length >= 3 ? "" : d + ".")) + }, 500) + return () => clearInterval(interval) + }, []) + + return ( + + + {`Counter: ${counter}${dots}`} + + Press Ctrl+C to exit + + ) +} + +if (process.env.NODE_TEST_CONTEXT === undefined && import.meta.main) { + const renderer = await createCliRenderer() + createRoot(renderer).render() +} diff --git a/packages/react/dist-test/nodejs-typescript/package.json b/packages/react/dist-test/nodejs-typescript/package.json new file mode 100644 index 000000000..5bdb38ed9 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript/package.json @@ -0,0 +1,20 @@ +{ + "name": "@opentui/react-dist-test-nodejs-typescript", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "npx tsgo --project tsconfig.json", + "test": "node --test dist/index.js" + }, + "dependencies": { + "@opentui/core": "*", + "@opentui/react": "*", + "@typescript/native-preview": "latest", + "@types/react": "^19.0.0", + "@types/node": "^24.0.0", + "react": ">=19.0.0" + } +} diff --git a/packages/react/dist-test/nodejs-typescript/tsconfig.json b/packages/react/dist-test/nodejs-typescript/tsconfig.json new file mode 100644 index 000000000..e11ea4962 --- /dev/null +++ b/packages/react/dist-test/nodejs-typescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["index.tsx"] +} diff --git a/packages/react/package.json b/packages/react/package.json index b11ce2736..e3ae55cd5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -39,7 +39,10 @@ "build:examples": "bun examples/build.ts", "build:dev": "bun run scripts/build.ts --dev", "publish": "bun run scripts/publish.ts", - "test": "bun test" + "test:bun": "bun test", + "test:dist": "bun ../../scripts/dist-test.ts ./dist-test/*", + "test:nodejs": "npx vitest run", + "test": "bun run test:bun && bun run test:nodejs && bun run test:dist" }, "devDependencies": { "@types/bun": "latest", diff --git a/packages/react/src/reconciler/host-config.ts b/packages/react/src/reconciler/host-config.ts index 0ffbea9dd..8a6f818e5 100644 --- a/packages/react/src/reconciler/host-config.ts +++ b/packages/react/src/reconciler/host-config.ts @@ -2,7 +2,7 @@ import { TextNodeRenderable, TextRenderable, type Renderable } from "@opentui/co import pkgJson from "../../package.json" import { createContext } from "react" import type { HostConfig, ReactContext } from "react-reconciler" -import { DefaultEventPriority, NoEventPriority } from "react-reconciler/constants" +import { DefaultEventPriority, NoEventPriority } from "react-reconciler/constants.js" import { getComponentCatalogue } from "../components/index.js" import { textNodeKeys, type TextNodeKey } from "../components/text.js" import type { Container, HostContext, Instance, Props, PublicInstance, TextInstance, Type } from "../types/host.js" diff --git a/packages/react/src/reconciler/reconciler.ts b/packages/react/src/reconciler/reconciler.ts index cff788c04..75a2f7d86 100644 --- a/packages/react/src/reconciler/reconciler.ts +++ b/packages/react/src/reconciler/reconciler.ts @@ -1,7 +1,7 @@ import type { RootRenderable } from "@opentui/core" import React from "react" import ReactReconciler from "react-reconciler" -import { ConcurrentRoot } from "react-reconciler/constants" +import { ConcurrentRoot } from "react-reconciler/constants.js" import { hostConfig } from "./host-config.js" export const reconciler = ReactReconciler(hostConfig) diff --git a/packages/react/tests/__snapshots__/layout.test.tsx.nodejs.snap b/packages/react/tests/__snapshots__/layout.test.tsx.nodejs.snap new file mode 100644 index 000000000..b4169a402 --- /dev/null +++ b/packages/react/tests/__snapshots__/layout.test.tsx.nodejs.snap @@ -0,0 +1,195 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`React Renderer | Layout Tests > Basic Text Rendering > should render multiline text correctly 1`] = ` +"Line 1 +Line 2 +Line 3 + + +" +`; + +exports[`React Renderer | Layout Tests > Basic Text Rendering > should render simple text correctly 1`] = ` +"Hello World + + + + +" +`; + +exports[`React Renderer | Layout Tests > Basic Text Rendering > should render text with dynamic content 1`] = ` +"Counter: 42 + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should auto-enable border when borderColor is set 1`] = ` +"┌──────────────────┐ +│Colored Border │ +│ │ +│ │ +└──────────────────┘ + + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should auto-enable border when borderStyle is set 1`] = ` +"┌──────────────────┐ +│With Border │ +│ │ +│ │ +└──────────────────┘ + + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should auto-enable border when focusedBorderColor is set 1`] = ` +"┌──────────────────┐ +│Focused Border │ +│ │ +│ │ +└──────────────────┘ + + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should render absolute positioned boxes 1`] = ` +"┌────────┐ +│Box 1 │ +└────────┘ ┌────────┐ + │Box 2 │ + └────────┘ + + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should render basic box layout correctly 1`] = ` +"┌──────────────────┐ +│Inside Box │ +│ │ +│ │ +└──────────────────┘ + + + +" +`; + +exports[`React Renderer | Layout Tests > Box Layout Rendering > should render nested boxes correctly 1`] = ` +"┌─Parent Box─────────────────┐ +│ │ +│ │ +│ ┌────────┐ │ +│ │Nested │ │ +│ └────────┘ │ +│ Sibling │ +│ │ +│ │ +└────────────────────────────┘ + + +" +`; + +exports[`React Renderer | Layout Tests > Complex Layouts > should render complex nested layout correctly 1`] = ` +"┌─Complex Layout───────────────────────┐ +│ ┌─────────────┐ │ +│ │Header Sectio│ │ +│ │Menu Item 1 │ │ +│ │Menu Item 2 │ │ +│ └─────────────┘ │ +│ ┌────────────────┐ │ +│ │Content Area │ │ +│ │Some content her│ │ +│ │More content │ │ +│ │Footer text │ │ +│ │ │ │ +│ │ │ │ +│ └────────────────┘ │ +│ Status: Ready │ +└──────────────────────────────────────┘ + + +" +`; + +exports[`React Renderer | Layout Tests > Complex Layouts > should render scrollbox with sticky scroll and spacer 1`] = ` +"┌─scroll area────────────────┐ +│ │ +│┌─hi───────────────────────┐│ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│└──────────────────────────┘│ +│ │ +│ │ +└────────────────────────────┘ +┌─spacer─────────────────────┐ +│spacer │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────┘ +" +`; + +exports[`React Renderer | Layout Tests > Complex Layouts > should render text with mixed styling and layout 1`] = ` +"┌─────────────────────────────────┐ +│ERROR: Something went wrong │ +│WARNING: Check your settings │ +│SUCCESS: All systems operational │ +│ │ +│ │ +│ │ +└─────────────────────────────────┘ + + +" +`; + +exports[`React Renderer | Layout Tests > Empty and Edge Cases > should handle component with no children 1`] = ` +" + + + + + + + +" +`; + +exports[`React Renderer | Layout Tests > Empty and Edge Cases > should handle empty component 1`] = ` +" + + + + +" +`; + +exports[`React Renderer | Layout Tests > Empty and Edge Cases > should handle very small dimensions 1`] = ` +"Hi + + +" +`; diff --git a/packages/react/tests/__snapshots__/layout.test.tsx.snap b/packages/react/tests/__snapshots__/layout.test.tsx.snap index a36b616f8..61512bff7 100644 --- a/packages/react/tests/__snapshots__/layout.test.tsx.snap +++ b/packages/react/tests/__snapshots__/layout.test.tsx.snap @@ -191,5 +191,1165 @@ exports[`React Renderer | Layout Tests Empty and Edge Cases should handle very s "Hi +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render basic text content correctly 1`] = ` +" + + + Hello World + +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render multiline text content correctly 1`] = ` +" + Line 1: Hello + Line 2: World + Line 3: Testing + Line 4: Multiline +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render text with graphemes/emojis correctly 1`] = ` +" + +Hello 🌍 World 👋 + Test 🚀 Emoji + +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render TextNode text composition correctly 1`] = ` +"First Second Third + + + + +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render text positioning correctly 1`] = ` +"Top + + Mid + + Bot +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render empty buffer correctly 1`] = ` +" + + + + +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render text with character wrapping correctly 1`] = ` +"This is a very +long text that +should wrap to +multiple lines +when wrap is en +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render wrapped text with different content 1`] = ` +" + ABCDEFGHIJ + KLMNOPQRST + UVWXYZ abc + defghijklm +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render wrapped text with emojis and graphemes 1`] = ` +" Hello 🌍 Wor + ld 👋 This i + s a test wit + h emojis 🚀 + that should +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render wrapped multiline text correctly 1`] = ` +" +First li +ne with +long con +tent +" +`; + +exports[`TextRenderable Selection Text Content Snapshots should render text with tab indicator correctly 1`] = ` +"Line 1→ Tabbed +Line 2→ → Double tab + + + +" +`; + +exports[`TextRenderable Selection Text Node Dimension Updates should update dimensions and reposition subsequent elements when text nodes expand 1`] = ` +"Short +Second text + + + +" +`; + +exports[`TextRenderable Selection Text Node Dimension Updates should update dimensions and reposition subsequent elements when text nodes expand 2`] = ` +"Short text that will + definitely wrap +Second text + + +" +`; + +exports[`TextRenderable Selection Text Node Dimension Updates should handle multiple text node updates with complex layout changes 1`] = ` +"First part +Middle text +Bottom text + + + + + + + +" +`; + +exports[`TextRenderable Selection Text Node Dimension Updates should handle multiple text node updates with complex layout changes 2`] = ` +"First of +a +sentence +partthat +will wrap +Middle text +Bottom text + + + +" +`; + +exports[`TextRenderable Selection Width/Height Setter Layout Tests should not shrink box when width is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection Width/Height Setter Layout Tests should not shrink box when height is set via setter in column layout with text 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`TextRenderable Selection Width/Height Setter Layout Tests should not shrink box when minWidth is set via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection Width/Height Setter Layout Tests should not shrink box when minHeight is set via setter in column layout with text 1`] = ` +"┌───────────────────────┐ +│Header │ +│ │ +│ │ +│Line1 │ +│Line2 │ +│Line3 │ +│Footer │ +│ │ +└───────────────────────┘ + + + + + +" +`; + +exports[`TextRenderable Selection Width/Height Setter Layout Tests should not shrink box when width is set from undefined via setter 1`] = ` +"┌────────────────────────────┐ +│> Content that takes up │ +│ space │ +└────────────────────────────┘ + + + + + + +" +`; + +exports[`TextRenderable Selection Absolute Positioned Box with Text should render text in absolute positioned box with padding and borders correctly 1`] = ` +" + + │ │ + │ │ + │ Important Notification │ + │ │ + │ │ + │ This is a longer message that should wrap properly within + │ │ + │ │ + + + + + + + + + + +" +`; + +exports[`TextRenderable Selection Absolute Positioned Box with Text should render text fully visible in absolute positioned box at various positions 1`] = ` +" + ┌──────────────────────────────────────┐ + │ Error: File not found in the │ + │ specified directory path │ + └──────────────────────────────────────┘ + + + + + + + + + + + + + + + + ─────────────────────────────────── + Success: Operation completed + successfully! + ─────────────────────────────────── + +" +`; + +exports[`TextRenderable Selection Absolute Positioned Box with Text should handle width:100% text in absolute positioned box with constrained maxWidth 1`] = ` +" + + + + + + + This is an extremely long piece of text + that needs to wrap multiple times within + the constrained width of the absolutely + positioned container box with significant + padding on all sides. + + + +" +`; + +exports[`TextRenderable Selection Absolute Positioned Box with Text should render multiple text elements in absolute positioned box with proper spacing 1`] = ` +" + + + ┌───────────────────────────────────────────┐ + │ │ + │ System Update │ + │ │ + │ A new version is available with bug │ + │ fixes and performance improvements. │ + │ │ + │ Click to install │ + │ │ + └───────────────────────────────────────────┘ + + + + + + + +" +`; + +exports[`TextRenderable Selection Word Wrapping should wrap at word boundaries when using word mode 1`] = ` +"The quick +brown fox +jumps over the +lazy dog + +" +`; + +exports[`TextRenderable Selection Word Wrapping should wrap at character boundaries when using char mode 1`] = ` +"The quick brown + fox jumps over + the lazy dog + + +" +`; + +exports[`TextRenderable Selection Word Wrapping should handle word wrapping with punctuation 1`] = ` +"Hello, +World. +Test- +Example/ +Path +" +`; + +exports[`TextRenderable Selection Word Wrapping should handle word wrapping with hyphens and dashes 1`] = ` +"self- +contained +multi-line +text- +wrapping +" +`; + +exports[`TextRenderable Selection Word Wrapping should dynamically change wrap mode 1`] = ` +"The quick +brown fox +jumps + + +" +`; + +exports[`TextRenderable Selection Word Wrapping should handle long words that exceed wrap width in word mode 1`] = ` +"ABCDEFGHIJ +KLMNOPQRST +UVWXYZ + + +" +`; + +exports[`TextRenderable Selection Word Wrapping should preserve empty lines with word wrapping 1`] = ` +"First +line + +Third +line +" +`; + +exports[`TextRenderable Selection Word Wrapping should handle word wrapping with single character words 1`] = ` +"a b c d +e f g h +i j k l +m n o p + +" +`; + +exports[`TextRenderable Selection Word Wrapping should compare char vs word wrapping with same content 1`] = ` +"Hello +wonderful +world of +text +wrapping +" +`; + +exports[`TextRenderable Selection Word Wrapping should correctly wrap text when updating content via text.content 1`] = ` +"Short text + + + + +" +`; + +exports[`TextRenderable Selection Word Wrapping should correctly wrap text when updating content via text.content 2`] = ` +"This is a much +longer text that +should definitely +wrap to multiple +lines +" +`; + +exports[`TextTableRenderable renders a basic table with styled cell chunks: basic table 1`] = ` +" + ┌─────┬──────┬───────────────────┐ + │Name │Status│Notes │ + ├─────┼──────┼───────────────────┤ + │Alpha│OK │All systems nominal│ + ├─────┼──────┼───────────────────┤ + │Bravo│WARN │Pending checks │ + └─────┴──────┴───────────────────┘ + + + + + + + + +" +`; + +exports[`TextTableRenderable wraps content and fits columns when width is constrained: wrapped constrained width 1`] = ` +"┌──┬─────────────────────────────┐ +│ID│Description │ +├──┼─────────────────────────────┤ +│1 │This is a long sentence that │ +│ │should wrap across multiple │ +│ │visual lines │ +├──┼─────────────────────────────┤ +│2 │Short │ +└──┴─────────────────────────────┘ + + + + + + + +" +`; + +exports[`TextTableRenderable balanced fitter keeps constrained columns visually closer: fitter proportional constrained 1`] = ` +"┌────┬─────────────┬─────────┬─────────┬───────┬─────────┐ +│Prov│Compute │Storage │Pricing │Regions│Use Cases│ +│ider│Services │Solutions│Model │ │ │ +├────┼─────────────┼─────────┼─────────┼───────┼─────────┤ +│Amaz│EC2 │S3 tiers,│Pay as │Global │Enterpris│ +│on W│instances │ EBS, │you go, │regions│e migrati│ +│eb S│with │EFS, and │reserved │ and ma│on, analy│ +│ervi│extensive │archive │terms, │ny edge│tics, ML,│ +│ces │options for │classes │and │ locati│ and back│ +│ │general, │for long │discounte│ons │end servi│ +│ │memory, and │retention│d spot ca│ │ces │ +│ │accelerated │ │pacity │ │ │ +│ │workloads │ │ │ │ │ +└────┴─────────────┴─────────┴─────────┴───────┴─────────┘ + + +" +`; + +exports[`TextTableRenderable balanced fitter keeps constrained columns visually closer: fitter balanced constrained 1`] = ` +"┌────────┬────────┬─────────┬─────────┬────────┬─────────┐ +│Provider│Compute │Storage │Pricing │Regions │Use Cases│ +│ │Services│Solutions│Model │ │ │ +├────────┼────────┼─────────┼─────────┼────────┼─────────┤ +│Amazon │EC2 │S3 tiers,│Pay as │Global │Enterpris│ +│Web │instance│ EBS, │you go, │regions │e migrati│ +│Services│s with e│EFS, and │reserved │and │on, analy│ +│ │xtensive│archive │terms, │many │tics, ML,│ +│ │ options│classes │and │edge │ and back│ +│ │ for gen│for long │discounte│location│end servi│ +│ │eral, me│retention│d spot ca│s │ces │ +│ │mory, an│ │pacity │ │ │ +│ │d accele│ │ │ │ │ +│ │rated wo│ │ │ │ │ +│ │rkloads │ │ │ │ │ +└────────┴────────┴─────────┴─────────┴────────┴─────────┘ +" +`; + +exports[`TextTableRenderable rebuilds table when content setter is used: content setter update 1`] = ` +"┌─────┬───────┐ +│Col 1│Col 2 │ +├─────┼───────┤ +│row-1│updated│ +├─────┼───────┤ +│row-2│active │ +└─────┴───────┘ + + + + + + + + + +" +`; + +exports[`TextTableRenderable keeps borders aligned with CJK and emoji content: unicode border alignment 1`] = ` +"┌──────┬──────────────┐ +│Locale│Sample │ +├──────┼──────────────┤ +│ja-JP │東京で寿司 🍣 │ +├──────┼──────────────┤ +│zh-CN │你好世界 🚀 │ +├──────┼──────────────┤ +│ko-KR │한글 테스트 😄│ +└──────┴──────────────┘ + + + + + + + +" +`; + +exports[`TextTableRenderable wraps CJK and emoji without grapheme duplication: unicode wrapping 1`] = ` +"┌───┬────────────────────────┐ +│Ite│Details │ +│m │ │ +├───┼────────────────────────┤ +│mix│東京界 🌍 emoji │ +│ed │wrapping continues │ +│ │across lines for width │ +│ │checks │ +├───┼────────────────────────┤ +│emo│Faces 😀😃😄 should │ +│ji │remain stable │ +└───┴────────────────────────┘ + + + + +" +`; + +exports[`TextTableRenderable keeps full wrapped table layouts after a wide-to-narrow demo-style resize: demo resize expected primary table 1`] = ` +"┌─────────────────────────┬─────────────┬────────────────────────────┐ +│Task │Owner │ETA │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Wrap regression in │core │done after validating none, │ +│operational status │platform and │word, and char wrap modes │ +│dashboard with dynamic │runtime │across narrow, medium, wide,│ +│row heights and │reliability │ and ultra-wide terminal │ +│constrained layout │squad │widths │ +│validation │ │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Unicode layout │render │in review with follow-up │ +│stabilization for mixed │pipeline │checks for border style │ +│Latin, punctuation, │maintainers │transitions, cell padding │ +│symbols, and long │with │variants, and selection │ +│identifiers in adjacent │fallback │range consistency │ +│columns │shaping │ │ +│ │support │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Snapshot pass for table │qa │today pending final │ +│rendering in content │automation │baseline updates for │ +│mode and full mode with │and visual │oversized fixtures that │ +│heavy and double border │diff triage │intentionally stress │ +│combinations │group │wrapping behavior on high- │ +│ │ │resolution terminals │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Document edge cases │developer │planned for this sprint │ +│where long tokens │experience │once final reproducible │ +│without spaces force │and docs │examples are captured and │ +│char wrapping and reveal │tooling │linked to regression │ +│per-cell clipping │ │tracking tickets │ +│regressions │ │ │ +├─────────────────────────┼─────────────┼────────────────────────────┤ +│Performance sweep of │runtime │scheduled after review, │ +│wrapping algorithm under │performance │with benchmark runs on │ +│large datasets to │task force │laptop and desktop │ +│confirm stable frame │ │terminals at 200-plus │ +│times during rapid key │ │column widths │ +│toggling │ │ │ +└─────────────────────────┴─────────────┴────────────────────────────┘ +" +`; + +exports[`TextTableRenderable keeps full wrapped table layouts after a wide-to-narrow demo-style resize: demo resize expected unicode table 1`] = ` +"┌─────┬──────────────────────────────────────────────────────────────┐ +│Colum│Wrapped Text │ +│n │ │ +├─────┼──────────────────────────────────────────────────────────────┤ +│mixed│CJK and emoji wrapping stress case: こんにちは世界 and │ +│-lang│안녕하세요 세계 and 你好,世界 followed by long English prose │ +│uages│that keeps flowing to test whether each cell wraps naturally │ +│ │even when the terminal is extremely wide and the row still │ +│ │needs multiple visual lines for readability 🌍🚀 │ +├─────┼──────────────────────────────────────────────────────────────┤ +│emoji│Faces 😀😃😄😁😆 plus symbols 🧪📦🛰️🔧📊 mixed with version │ +│-and-│tags like release-candidate-build-2026-02-very-long-token- │ +│symbo│without-breaks to ensure char wrapping remains stable and no │ +│ls │glyph alignment issues appear at column boundaries │ +├─────┼──────────────────────────────────────────────────────────────┤ +│long-│長文の日本語テキストと中文段落和한국어문장을連続して配置し、 │ +│cjk- │その後に additional English context describing renderer │ +│phras│behavior, border intersection handling, and selection │ +│e │extraction so that this single cell remains a reliable │ +│ │wrapping torture test. │ +├─────┼──────────────────────────────────────────────────────────────┤ +│mixed│Wrap behavior with punctuation-heavy content: [alpha]{beta}( │ +│-punc│gamma)|epsilon| then repeated fragments, commas, │ +│tuati│semicolons, and slashes to verify token boundaries do not │ +│on │break border drawing logic or spacing consistency in │ +│ │neighboring columns. │ +└─────┴──────────────────────────────────────────────────────────────┘ +" +`; + +exports[`Absolute Positioning - Snapshot Tests Basic absolute positioning absolute positioned box at top-left: absolute positioned box at top-left 1`] = ` +"┌─────────────┐ +│Top Left │ +│ │ +│ │ +└─────────────┘ + + + + + + + + + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Basic absolute positioning absolute positioned box at bottom-right using right/bottom: absolute positioned box at bottom-right 1`] = ` +" + + + + + + + + + + + + + + + ┌─────────────┐ + │Bottom Right │ + │ │ + │ │ + └─────────────┘ +" +`; + +exports[`Absolute Positioning - Snapshot Tests Basic absolute positioning absolute positioned box centered with left/top: absolute positioned box centered 1`] = ` +" + + + + + ┌──────────────────┐ + │Centered │ + │ │ + │ │ + │ │ + │ │ + │ │ + └──────────────────┘ + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Nested absolute positioning absolute child inside absolute parent - basic: nested absolute - child inside parent at left/top 1`] = ` +" + + + ┌────────────────────────────┐ + │ │ + │ ┌──────────┐ │ + │ │Nested │ │ + │ │ │ │ + │ └──────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────┘ + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Nested absolute positioning absolute child at bottom:0 inside absolute parent (issue #406 fix): nested absolute - child at bottom:0 of parent 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌─────────────┐ │ + │ │At Bottom │ │ + │ └─────────────┘ │ + └────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Nested absolute positioning absolute child at right:0 inside absolute parent: nested absolute - child at right:0 of parent 1`] = ` +" + + ┌─────────────────────────────────┐ + │ │ + │ ┌──────────┐│ + │ │At Right ││ + │ │ ││ + │ └──────────┘│ + │ │ + │ │ + │ │ + │ │ + │ │ + └─────────────────────────────────┘ + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Nested absolute positioning absolute child at bottom-right corner inside absolute parent: nested absolute - child at bottom-right corner 1`] = ` +" + ┌────────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌────────────┐ │ + │ │Corner │ │ + │ │ │ │ + │ └────────────┘ │ + │ │ + └────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Nested absolute positioning multiple absolute children inside absolute parent at different positions: nested absolute - four corners inside parent 1`] = ` +" + ┌──────────────────────────────────┐ + │ │ + │ ┌────────┐ ┌────────┐ │ + │ │TL │ │TR │ │ + │ └────────┘ └────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌────────┐ ┌────────┐ │ + │ │BL │ │BR │ │ + │ └────────┘ └────────┘ │ + │ │ + └──────────────────────────────────┘ + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Three-level nesting deeply nested absolute positioning - grandchild at bottom: three-level nested absolute - grandchild at bottom 1`] = ` +" + ┌────────────────────────────────────┐ + │ │ + │ │ + │ ┌──────────────────────────────┐ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ┌─────────────┐ │ │ + │ │ │Deep │ │ │ + │ │ └─────────────┘ │ │ + │ │ │ │ + │ └──────────────────────────────┘ │ + │ │ + │ │ + └────────────────────────────────────┘ + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Mixed positioning absolute child inside relative parent: absolute child inside relative parent 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌──────────┐ │ + │ │Absolute │ │ + │ │ │ │ + │ └──────────┘ │ + │ │ + └────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Mixed positioning sibling absolute elements at same level: sibling absolute elements overlapping 1`] = ` +"┌─────────────┐ +│Box 1 │ +│ │ +│ │ +│ ┌─────────────┐ +└───────────│Box 2 │ + │ │ + │ │ + │ ┌─────────────┐ + └───────────│Box 3 │ + │ │ + │ │ + │ │ + └─────────────┘ + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute positioned box with negative coordinates (partially off-screen): absolute box with negative coordinates 1`] = ` +" │ + │ + │ + │ + │ +──────────────┘ + + + + + + + + + + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute positioned box extending beyond viewport: absolute box extending beyond viewport 1`] = ` +" + + + + + + + + + + + + + + + ┌───────── + │Overflow + │ + │ + │ +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute child fills parent completely: absolute child fills parent with inset 0 1`] = ` +" + + + ┌────────────────────────────┐ + │╔══════════════════════════╗│ + │║Full ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │║ ║│ + │╚══════════════════════════╝│ + └────────────────────────────┘ + + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute positioned box with percentage width inside absolute parent: absolute child with percentage width 1`] = ` +" + + ┌──────────────────────────────────────┐ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ┌─────────────────┐ │ + │ │50% │ │ + │ │ │ │ + │ └─────────────────┘ │ + │ │ + └──────────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute positioned box with percentage height inside absolute parent: absolute child with percentage height 1`] = ` +" + + ┌────────────────────────────┐ + │ │ + │ ┌─────────────┐ │ + │ │50% H │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ └─────────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────┘ + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute child with conflicting insets (left and right without explicit width): absolute child with left and right insets (no explicit width) 1`] = ` +" + + ┌────────────────────────────────┐ + │ │ + │ │ + │ ┌──────────────────────────┐ │ + │ │Stretch │ │ + │ │ │ │ + │ │ │ │ + │ └──────────────────────────┘ │ + │ │ + │ │ + │ │ + │ │ + │ │ + └────────────────────────────────┘ + + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Edge cases absolute child with conflicting insets (top and bottom without explicit height): absolute child with top and bottom insets (no explicit height) 1`] = ` +" + ┌────────────────────────────┐ + │ │ + │ ┌─────────────┐ │ + │ │VStretch │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ └─────────────┘ │ + │ │ + └────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Complex hierarchies relative parent with absolute child containing absolute grandchild: relative -> absolute -> absolute hierarchy 1`] = ` +" + ┌─────────────────────────────────┐ + │ │ + │ ┌──────────────────────────┐ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ┌──────────┐ │ │ + │ │ │Grand │ │ │ + │ │ │ │ │ │ + │ │ └──────────┘ │ │ + │ │ │ │ + │ └──────────────────────────┘ │ + │ │ + └─────────────────────────────────┘ + + + +" +`; + +exports[`Absolute Positioning - Snapshot Tests Complex hierarchies multiple nested relative and absolute layers: relative -> absolute -> relative -> absolute hierarchy 1`] = ` +"┌────────────────────────────────────┐ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────┐ │ │ │ +│ │ │ │Deep │ │ │ │ +│ │ │ └────────┘ │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +│ │ +└────────────────────────────────────┘ + + +" +`; + +exports[`ScrollBoxRenderable - Content Visibility scrolls CodeRenderable with LineNumberRenderable using mouse wheel 1`] = ` +" 22 Line 22 + 23 Line 23 + 24 Line 24 + 25 Line 25 + 26 Line 26 + 27 Line 27 + 28 Line 28 + 29 Line 29 + 30 Line 30 + 31 ▄ + + + + + + + + + + + + + + +" +`; + +exports[`Renderable - insertBefore reproduces insertBefore behavior with state change after timeout: insertBefore initial state 1`] = ` +"banana +apple +pear + + +" +`; + +exports[`Renderable - insertBefore reproduces insertBefore behavior with state change after timeout: insertBefore reordered state 1`] = ` +"banana +pear +apple + + " `; diff --git a/packages/react/tests/destroy-crash.test.tsx b/packages/react/tests/destroy-crash.test.tsx index 2efa57961..0d454413d 100644 --- a/packages/react/tests/destroy-crash.test.tsx +++ b/packages/react/tests/destroy-crash.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import React, { useEffect, useState } from "react" import { createTestRenderer } from "@opentui/core/testing" import { createRoot } from "../src/reconciler/renderer.js" +import { sleep } from "@opentui/core/compat/runtime" /** * Regression test for: Native Yoga crash when renderer.destroy() is called @@ -73,7 +74,7 @@ describe("Renderer Destroy Crash with Pending React Updates", () => { // Let the component mount and interval start await testSetup.renderOnce() - await Bun.sleep(30) + await sleep(30) await testSetup.renderOnce() // Destroy WITHOUT unmounting React - this is the bug! @@ -83,7 +84,7 @@ describe("Renderer Destroy Crash with Pending React Updates", () => { // Wait for interval to fire more updates after destroy // This is when the crash occurs if the bug is present - await Bun.sleep(100) + await sleep(100) // If we reach here without crashing, the bug is fixed expect(true).toBe(true) diff --git a/packages/react/tests/runtime-plugin-support.fixture.ts b/packages/react/tests/runtime-plugin-support.fixture.ts index bafb342b2..656b82be1 100644 --- a/packages/react/tests/runtime-plugin-support.fixture.ts +++ b/packages/react/tests/runtime-plugin-support.fixture.ts @@ -22,7 +22,7 @@ type FixtureState = typeof globalThis & { } const tempRoot = mkdtempSync(join(tmpdir(), "react-runtime-plugin-support-fixture-")) -const entryPath = join(tempRoot, "entry.ts") +const entryPath = join(tempRoot, "entry.js") const source = [ 'import * as core from "@opentui/core"', @@ -33,7 +33,7 @@ const source = [ 'import * as react from "react"', 'import * as reactJsx from "react/jsx-runtime"', 'import * as reactJsxDev from "react/jsx-dev-runtime"', - "const state = globalThis as { __reactRuntimeHost__?: { core: Record; coreTesting: Record; opentuiReact: Record; opentuiReactJsx: Record; opentuiReactJsxDev: Record; react: Record; reactJsx: Record; reactJsxDev: Record } }", + "const state = globalThis", "const host = state.__reactRuntimeHost__", "const checks = [", " `core=${core.engine === host?.core.engine}`,", diff --git a/packages/react/tests/runtime-plugin-support.test.ts b/packages/react/tests/runtime-plugin-support.test.ts index fbd0c3b35..f1128d378 100644 --- a/packages/react/tests/runtime-plugin-support.test.ts +++ b/packages/react/tests/runtime-plugin-support.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "bun:test" import { join } from "node:path" +import { spawnSync } from "@opentui/core/compat/testHelpers" + +const bunIt = process.versions.bun ? it : it.skip describe("react runtime plugin support", () => { - it("loads external modules against host runtime exports", () => { - const fixturePath = join(import.meta.dir, "runtime-plugin-support.fixture.ts") - const result = Bun.spawnSync([process.execPath, fixturePath], { - cwd: join(import.meta.dir, ".."), + bunIt("loads external modules against host runtime exports", () => { + const fixturePath = join(import.meta.dirname, "runtime-plugin-support.fixture.ts") + const result = spawnSync([process.execPath, fixturePath], { + cwd: join(import.meta.dirname, ".."), stdout: "pipe", stderr: "pipe", env: process.env, diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 000000000..c06cd841b --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,17 @@ +import { basename, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + "bun:test": fileURLToPath(new URL("../core/src/compat/test.ts", import.meta.url)), + }, + }, + test: { + environment: "node", + resolveSnapshotPath: (testPath, ext) => + join(dirname(testPath), "__snapshots__", `${basename(testPath)}.nodejs${ext}`), + exclude: ["node_modules/**", "dist-test/**"], + }, +}) diff --git a/packages/solid/dist-test/nodejs-typescript/build.mjs b/packages/solid/dist-test/nodejs-typescript/build.mjs new file mode 100644 index 000000000..aeb177016 --- /dev/null +++ b/packages/solid/dist-test/nodejs-typescript/build.mjs @@ -0,0 +1,35 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { transformAsync } from "@babel/core" +import ts from "@babel/preset-typescript" +import solid from "babel-preset-solid" + +const rootDir = dirname(fileURLToPath(import.meta.url)) +const inputPath = join(rootDir, "index.tsx") +const outputDir = join(rootDir, "dist") +const outputPath = join(outputDir, "index.js") + +const input = readFileSync(inputPath, "utf8") +const transformed = await transformAsync(input, { + filename: inputPath, + configFile: false, + babelrc: false, + presets: [ + [ + solid, + { + moduleName: "@opentui/solid", + generate: "universal", + }, + ], + [ts], + ], +}) + +if (!transformed?.code) { + throw new Error(`Failed to transform ${inputPath}`) +} + +mkdirSync(outputDir, { recursive: true }) +writeFileSync(outputPath, transformed.code) diff --git a/packages/solid/dist-test/nodejs-typescript/index.tsx b/packages/solid/dist-test/nodejs-typescript/index.tsx new file mode 100644 index 000000000..635a41acf --- /dev/null +++ b/packages/solid/dist-test/nodejs-typescript/index.tsx @@ -0,0 +1,102 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { render, testRender, useRenderer } from "@opentui/solid" +import { onMount } from "solid-js" + +const initialDraft = "Welcome to the Solid dist test." + +export function SolidDistTextareaDemo() { + const renderer = useRenderer() + + onMount(() => { + renderer.setBackgroundColor("#111827") + }) + + return ( + + +