diff --git a/README.md b/README.md index 92c636b..9b6259a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,22 @@ # @webgui/react +> **Deprecated.** Use [`@webgui/client`](https://www.npmjs.com/package/@webgui/client) instead. +> +> ```bash +> npm install @webgui/client +> ``` +> +> ```diff +> -import { ... } from '@webgui/react' +> +import { ... } from '@webgui/client/react' +> ``` +> +> The React API is unchanged — this package now re-exports `@webgui/client/react`, +> so nothing breaks if you stay. New events and hooks land in `@webgui/client` only. + +--- + + > **Requires the [WebGUI Minecraft mod](https://modrinth.com/mod/webgui)** ([GitHub](https://github.com/mc-webgui/webgui)) to be installed on the client. This library has no effect outside of the mod. React hooks and TypeScript types for SPAs running inside the **WebGUI** Minecraft mod (free edition). diff --git a/package-lock.json b/package-lock.json index 4063467..c46d3c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@webgui/react", - "version": "1.6.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@webgui/react", - "version": "1.6.0", + "version": "1.8.0", "license": "MIT", + "dependencies": { + "@webgui/client": "^1.8.0" + }, "devDependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -1274,6 +1277,31 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgui/client": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@webgui/client/-/client-1.8.0.tgz", + "integrity": "sha512-aLqGcOEGoZRXNL5RUBCDtHBGT5yLUwehHw0EJoHxHw/rz4wbc1Vt3MfstVPyAjoLKEhCalx5Il7kdvbaQRlsoQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1985,7 +2013,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/jsdom": { @@ -2063,7 +2091,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -2390,7 +2418,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" diff --git a/package.json b/package.json index 0de6398..6140ef0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@webgui/react", - "version": "1.6.0", - "description": "React hooks and types for the WebGUI Minecraft mod — access window.webgui client data from any SPA.", + "version": "1.8.0", + "description": "Deprecated — use @webgui/client/react. Re-export kept for existing installs.", "keywords": [ "minecraft", "webgui", @@ -63,5 +63,8 @@ }, "publishConfig": { "access": "public" + }, + "dependencies": { + "@webgui/client": "^1.8.0" } } diff --git a/src/entityStore.ts b/src/entityStore.ts deleted file mode 100644 index f7614c9..0000000 --- a/src/entityStore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { WebGUIEntity } from './types'; - -type Listener = () => void; - -let _entity: WebGUIEntity | null = null; -const _listeners = new Set(); - -function notify() { - _listeners.forEach((l) => l()); -} - -export function subscribe(listener: Listener): () => void { - _listeners.add(listener); - return () => _listeners.delete(listener); -} - -export function getSnapshot(): WebGUIEntity | null { - return _entity; -} - -export function getServerSnapshot(): null { - return null; -} - -if (typeof globalThis.window !== 'undefined') { - _entity = globalThis.window.webgui?.entity ?? null; - - globalThis.window.addEventListener('webgui:entity', (e) => { - _entity = (e as CustomEvent).detail; - notify(); - }); -} diff --git a/src/hooks/useCloseGui.ts b/src/hooks/useCloseGui.ts deleted file mode 100644 index 6e51743..0000000 --- a/src/hooks/useCloseGui.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useCallback } from 'react'; - -export function useCloseGui(): () => void { - return useCallback(() => { - if (typeof globalThis.window !== 'undefined') { - globalThis.window.webgui?.closeGui(); - } - }, []); -} diff --git a/src/hooks/usePostToGame.ts b/src/hooks/usePostToGame.ts deleted file mode 100644 index ff31d08..0000000 --- a/src/hooks/usePostToGame.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useCallback } from 'react'; -import type { PostToGamePayload } from '../types'; - -export function usePostToGame(): (payload: PostToGamePayload) => void { - return useCallback((payload: PostToGamePayload) => { - if (typeof globalThis.window === 'undefined') return; - globalThis.window.webgui?.postToGame(payload); - }, []); -} diff --git a/src/hooks/useRunCommand.ts b/src/hooks/useRunCommand.ts deleted file mode 100644 index ea9d7ce..0000000 --- a/src/hooks/useRunCommand.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useCallback } from 'react'; -import { runCommand } from '../runCommand'; - -/** - * Returns a stable {@link runCommand} callback. - * - * @example - * const run = useRunCommand(); - * - */ -export function useRunCommand(): (command: string) => void { - return useCallback((command: string) => runCommand(command), []); -} diff --git a/src/hooks/useWebGUIClient.ts b/src/hooks/useWebGUIClient.ts deleted file mode 100644 index f419392..0000000 --- a/src/hooks/useWebGUIClient.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useSyncExternalStore } from 'react'; -import { subscribe, getSnapshot, getServerSnapshot } from '../store'; -import type { WebGUIClient } from '../types'; - -export function useWebGUIClient(): WebGUIClient | null { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} diff --git a/src/hooks/useWebGUIEntity.ts b/src/hooks/useWebGUIEntity.ts deleted file mode 100644 index 4eb9975..0000000 --- a/src/hooks/useWebGUIEntity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSyncExternalStore } from 'react'; -import { subscribe, getSnapshot, getServerSnapshot } from '../entityStore'; -import type { WebGUIEntity } from '../types'; - -/** - * Returns the entity the player right-clicked to open this GUI, - * or `null` if the GUI was opened via command rather than entity interaction. - * - * Re-renders whenever the entity context changes (e.g. when the player opens - * another entity GUI without closing the first one). - * - * @example - * export function ShopHeader() { - * const entity = useWebGUIEntity() - * if (!entity) return null - * return

{entity.name}

- * } - */ -export function useWebGUIEntity(): WebGUIEntity | null { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} diff --git a/src/hooks/useWebGUIEvent.ts b/src/hooks/useWebGUIEvent.ts deleted file mode 100644 index a71628c..0000000 --- a/src/hooks/useWebGUIEvent.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Listens for a named event emitted by the server via {@code WebviewApi.emitToPage}. - * - * @param eventName - the event name (without the "webgui:" prefix) - * @param handler - called with the event's {@code detail} payload each time the event fires - * - * @example - * useWebGUIEvent<{ balance: number }>('walletUpdate', ({ balance }) => { - * setBalance(balance) - * }) - */ -export function useWebGUIEvent( - eventName: string, - handler: (data: T) => void, -): void { - useEffect(() => { - const listener = (e: Event) => handler((e as CustomEvent).detail); - window.addEventListener(`webgui:${eventName}` as keyof WindowEventMap, listener as EventListener); - return () => { - window.removeEventListener(`webgui:${eventName}` as keyof WindowEventMap, listener as EventListener); - }; - }, [eventName, handler]); -} diff --git a/src/hooks/useWebGUISelector.ts b/src/hooks/useWebGUISelector.ts deleted file mode 100644 index d7a3ed9..0000000 --- a/src/hooks/useWebGUISelector.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useRef } from 'react'; -import { useSyncExternalStore } from 'react'; -import { subscribe, getSnapshot as getClientSnapshot, getServerSnapshot } from '../store'; -import type { WebGUIClient } from '../types'; - -export function useWebGUISelector( - selector: (client: WebGUIClient) => T, - equalFn: (a: T, b: T) => boolean = Object.is, -): T | null { - const prevRef = useRef(null); - - function getSnapshot(): T | null { - const c = getClientSnapshot(); - if (c === null) return null; - const next = selector(c); - if (prevRef.current !== null && equalFn(prevRef.current, next)) return prevRef.current; - prevRef.current = next; - return next; - } - - return useSyncExternalStore( - subscribe, - getSnapshot, - getServerSnapshot as () => T | null, - ); -} diff --git a/src/hooks/useWebGUIToken.ts b/src/hooks/useWebGUIToken.ts deleted file mode 100644 index 728d429..0000000 --- a/src/hooks/useWebGUIToken.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useSyncExternalStore } from 'react'; - -// Token is static for the page lifetime — subscribe is a no-op. -const NOOP_SUBSCRIBE = () => () => {}; - -/** - * Returns the signed token the mod appended to the current page URL, or `null` - * when the token is absent (tokens disabled on the server, or normal browser tab). - * - * The token is static for the page lifetime — it does not change between renders. - * Pass it to your backend for verification. - * - * Uses `useSyncExternalStore` so SSR always returns `null` without hydration - * mismatches. - * - * @param paramName Query parameter name configured via `queryParamName` in - * `server.json` (default: `"webgui_token"`). - */ -export function useWebGUIToken(paramName = 'webgui_token'): string | null { - return useSyncExternalStore( - NOOP_SUBSCRIBE, - () => new URLSearchParams(globalThis.window.location.search).get(paramName) ?? null, - () => null, - ); -} diff --git a/src/index.ts b/src/index.ts index 30b2b8a..e2b1c9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,10 @@ -// Types -export type { - Vec3, - ServerInfo, - WebviewMode, - Gamemode, - Look, - WebGUIClient, - WebGUIEntity, - WebGUINamespace, - PostToGameLog, - PostToGameMessage, - PostToGamePayload, -} from './types'; - -// Utils -export { isInMod, isReady } from './utils'; - -// Commands -export { runCommand } from './runCommand'; - -// Hooks -export { useWebGUIClient } from './hooks/useWebGUIClient'; -export { useWebGUIEntity } from './hooks/useWebGUIEntity'; -export { usePostToGame } from './hooks/usePostToGame'; -export { useWebGUISelector } from './hooks/useWebGUISelector'; -export { useCloseGui } from './hooks/useCloseGui'; -export { useWebGUIToken } from './hooks/useWebGUIToken'; -export { useWebGUIEvent } from './hooks/useWebGUIEvent'; -export { useRunCommand } from './hooks/useRunCommand'; +/** + * @deprecated Use `@webgui/client/react` instead. + * + * This package is now a re-export of that one, kept so existing installs keep working. + * The names are identical, so migrating is a change of import path: + * + * -import { ... } from '@webgui/react' + * +import { ... } from '@webgui/client/react' + */ +export * from '@webgui/client/react'; diff --git a/src/runCommand.ts b/src/runCommand.ts deleted file mode 100644 index e5f6980..0000000 --- a/src/runCommand.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Runs a Minecraft command as the player, exactly as if they had typed it in chat. - * - * Only works when the page is served from an origin the server declared trusted - * (`trustedCommandOrigins` in the mod's server config). From any other origin the mod - * silently drops the request. Fire-and-forget: command feedback appears in the game, not here. - * - * @param command the command, with or without a leading slash (e.g. `"give @s minecraft:diamond 1"`) - * - * @example - * runCommand('give @s minecraft:diamond 1'); - */ -export function runCommand(command: string): void { - if (typeof globalThis.window === 'undefined') return; - globalThis.window.webgui?.postToGame({ channel: 'command', command }); -} diff --git a/src/store.ts b/src/store.ts deleted file mode 100644 index c4b25d7..0000000 --- a/src/store.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { WebGUIClient } from './types'; - -type Listener = () => void; - -let _client: WebGUIClient | null = null; -const _listeners = new Set(); - -function notify() { - _listeners.forEach((l) => l()); -} - -export function subscribe(listener: Listener): () => void { - _listeners.add(listener); - return () => _listeners.delete(listener); -} - -export function getSnapshot(): WebGUIClient | null { - return _client; -} - -export function getServerSnapshot(): null { - return null; -} - -if (typeof globalThis.window !== 'undefined') { - // Capture value injected before React bootstrapped. - _client = globalThis.window.webgui?.client ?? null; - - globalThis.window.addEventListener('webgui:client', (e) => { - _client = (e as CustomEvent).detail; - notify(); - }); -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a4641b8..0000000 --- a/src/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -export interface Vec3 { - x: number; - y: number; - z: number; -} - -export interface ServerInfo { - address?: string; - /** Ping in ms */ - ping?: number; -} - -export type WebviewMode = 'GUI_SCREEN' | 'HUD_OVERLAY' | 'NONE'; - -export type Gamemode = 'survival' | 'creative' | 'adventure' | 'spectator'; - -/** Where the player is looking, in degrees. */ -export interface Look { - /** Rotation around the vertical axis, degrees. */ - yaw: number; - /** Up/down rotation, degrees (negative = up). */ - pitch: number; -} - -export interface WebGUIClient { - playerUuid: string; - username: string; - webviewMode: WebviewMode; - /** Namespaced dimension key, e.g. "minecraft:overworld" */ - dimension: string; - pos: Vec3; - /** Head rotation (yaw/pitch), degrees. */ - look: Look; - /** Current health points (0–maxHealth). */ - health: number; - /** Maximum health points. */ - maxHealth: number; - /** Food/hunger level (0–20). */ - food: number; - /** Experience level. */ - xpLevel: number; - /** Current game mode; absent if the client hasn't resolved it yet. */ - gamemode?: Gamemode; - server?: ServerInfo; -} - -export interface PostToGameLog { - channel: 'log'; - level?: 'info' | 'warn' | 'error' | 'debug'; - message: string; -} - -export interface PostToGameMessage { - channel: string; - [key: string]: unknown; -} - -export type PostToGamePayload = PostToGameLog | PostToGameMessage | string; - -/** The entity a player right-clicked to open the current GUI. Null when opened via command. */ -export interface WebGUIEntity { - /** Entity UUID */ - uuid: string; - /** Namespaced entity type, e.g. "minecraft:villager" */ - type: string; - /** Custom name if set, otherwise the entity type's display name */ - name: string; - pos: Vec3; -} - -export interface WebGUINamespace { - client?: WebGUIClient; - /** Set when the GUI was opened by interacting with an entity; null otherwise. */ - entity?: WebGUIEntity | null; - postToGame: (payload: PostToGamePayload) => void; - closeGui: () => void; - /** Subscribe to a named event emitted by the server via {@code WebviewApi.emitToPage}. */ - on: (eventName: string, handler: (data: unknown) => void) => void; - /** Unsubscribe a previously registered handler. */ - off: (eventName: string, handler: (data: unknown) => void) => void; - onClientInfo?: (client: WebGUIClient) => void; - /** @internal handler registry used by on/off */ - _hs?: Record void; w: EventListener }>>; -} - -declare global { - interface Window { - /** Injected by the WebGUI mod. Undefined in a normal browser tab. */ - webgui?: WebGUINamespace; - } - - interface WindowEventMap { - 'webgui:client': CustomEvent; - 'webgui:entity': CustomEvent; - } -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index cb780b3..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isInMod(): boolean { - return typeof globalThis.window?.webgui !== 'undefined'; -} - -export function isReady(client: unknown): boolean { - return client !== null && client !== undefined; -} diff --git a/test/clientFields.test.tsx b/test/clientFields.test.tsx deleted file mode 100644 index 0ee688a..0000000 --- a/test/clientFields.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import { makeClient, emitClient } from './helpers'; - -// Covers the player-stat fields the mod pushes on window.webgui.client: -// look, health, maxHealth, food, xpLevel, gamemode. -beforeEach(() => { - vi.resetModules(); -}); - -describe('client player-stat fields', () => { - it('round-trips the new fields through the store snapshot', async () => { - const store = await import('../src/store'); - const client = makeClient({ - look: { yaw: 90, pitch: -12.5 }, - health: 15.5, - maxHealth: 20, - food: 7, - xpLevel: 30, - gamemode: 'creative', - }); - emitClient(client); - - const snap = store.getSnapshot(); - expect(snap).not.toBeNull(); - expect(snap!.look).toEqual({ yaw: 90, pitch: -12.5 }); - expect(snap!.health).toBe(15.5); - expect(snap!.maxHealth).toBe(20); - expect(snap!.food).toBe(7); - expect(snap!.xpLevel).toBe(30); - expect(snap!.gamemode).toBe('creative'); - }); - - it('is selectable via useWebGUISelector (e.g. health ratio)', async () => { - const { useWebGUISelector } = await import('../src/hooks/useWebGUISelector'); - const { result } = renderHook(() => - useWebGUISelector((c) => c.health / c.maxHealth), - ); - expect(result.current).toBeNull(); - - act(() => emitClient(makeClient({ health: 10, maxHealth: 20 }))); - expect(result.current).toBe(0.5); - }); - - it('allows gamemode to be absent (optional field)', async () => { - const store = await import('../src/store'); - const { gamemode, ...noMode } = makeClient(); - void gamemode; - emitClient(noMode as ReturnType); - expect(store.getSnapshot()!.gamemode).toBeUndefined(); - }); -}); diff --git a/test/entityStore.test.ts b/test/entityStore.test.ts deleted file mode 100644 index 5641496..0000000 --- a/test/entityStore.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { installWebgui, makeEntity, emitEntity } from './helpers'; - -async function loadStore() { - return import('../src/entityStore'); -} - -describe('entityStore', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('starts null when opened via command (no entity)', async () => { - const store = await loadStore(); - expect(store.getSnapshot()).toBeNull(); - }); - - it('captures an entity injected before import', async () => { - const entity = makeEntity(); - installWebgui({ entity }); - const store = await loadStore(); - expect(store.getSnapshot()).toEqual(entity); - }); - - it('updates and notifies on webgui:entity events', async () => { - const store = await loadStore(); - const listener = vi.fn(); - store.subscribe(listener); - - const entity = makeEntity({ name: 'Shopkeeper' }); - emitEntity(entity); - - expect(store.getSnapshot()).toEqual(entity); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('clears back to null when a null entity event fires', async () => { - const store = await loadStore(); - emitEntity(makeEntity()); - expect(store.getSnapshot()).not.toBeNull(); - emitEntity(null); - expect(store.getSnapshot()).toBeNull(); - }); -}); diff --git a/test/exports.test.ts b/test/exports.test.ts new file mode 100644 index 0000000..e4daff0 --- /dev/null +++ b/test/exports.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import * as shim from '../src/index'; + +/** + * This package is a one-line re-export now, so the only thing that can break is the + * re-export itself: a wrong subpath or a name dropped upstream would leave importers + * with undefined at runtime and nothing at build time to say so. + */ +const PUBLIC_API = [ + 'isInMod', + 'isReady', + 'runCommand', + 'postToGame', + 'closeGui', + 'respawn', + 'useWebGUIClient', + 'useWebGUIEntity', + 'useWebGUISelector', + 'usePostToGame', + 'useCloseGui', + 'useRunCommand', + 'useWebGUIToken', + 'useWebGUIEvent', +]; + +describe('@webgui/client/react re-export', () => { + it.each(PUBLIC_API)('still exports %s', (name) => { + expect(shim[name as keyof typeof shim]).toBeDefined(); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts deleted file mode 100644 index 84bb038..0000000 --- a/test/helpers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { vi } from 'vitest'; -import type { - WebGUIClient, - WebGUIEntity, - WebGUINamespace, - PostToGamePayload, -} from '../src/types'; - -export function makeClient(overrides: Partial = {}): WebGUIClient { - return { - playerUuid: '00000000-0000-0000-0000-000000000001', - username: 'Steve', - webviewMode: 'GUI_SCREEN', - dimension: 'minecraft:overworld', - pos: { x: 0, y: 64, z: 0 }, - look: { yaw: 0, pitch: 0 }, - health: 20, - maxHealth: 20, - food: 20, - xpLevel: 0, - gamemode: 'survival', - ...overrides, - }; -} - -export function makeEntity(overrides: Partial = {}): WebGUIEntity { - return { - uuid: '00000000-0000-0000-0000-0000000000ff', - type: 'minecraft:villager', - name: 'Librarian', - pos: { x: 1, y: 65, z: 2 }, - ...overrides, - }; -} - -/** - * Installs a fake `window.webgui` namespace with spy-backed methods and - * returns it. The postToGame/closeGui/on/off functions are vi.fn() spies. - */ -export function installWebgui( - overrides: Partial = {}, -): WebGUINamespace & { - postToGame: ReturnType; - closeGui: ReturnType; -} { - const ns = { - postToGame: vi.fn((_payload: PostToGamePayload) => {}), - closeGui: vi.fn(() => {}), - on: vi.fn(), - off: vi.fn(), - ...overrides, - } as WebGUINamespace & { - postToGame: ReturnType; - closeGui: ReturnType; - }; - (window as unknown as { webgui: WebGUINamespace }).webgui = ns; - return ns; -} - -/** Fires a `webgui:client` CustomEvent the way the mod's injected script does. */ -export function emitClient(client: WebGUIClient | null): void { - window.dispatchEvent(new CustomEvent('webgui:client', { detail: client })); -} - -/** Fires a `webgui:entity` CustomEvent. */ -export function emitEntity(entity: WebGUIEntity | null): void { - window.dispatchEvent(new CustomEvent('webgui:entity', { detail: entity })); -} diff --git a/test/hooks.test.tsx b/test/hooks.test.tsx deleted file mode 100644 index 99e379b..0000000 --- a/test/hooks.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; -import { - installWebgui, - makeClient, - makeEntity, - emitClient, - emitEntity, -} from './helpers'; - -// The store-backed hooks share a module-level singleton, so import them -// freshly per test (after vi.resetModules) to isolate state. -beforeEach(() => { - vi.resetModules(); -}); - -describe('useWebGUIClient', () => { - it('returns null before any client, then the client after an event', async () => { - const { useWebGUIClient } = await import('../src/hooks/useWebGUIClient'); - const { result } = renderHook(() => useWebGUIClient()); - expect(result.current).toBeNull(); - - const client = makeClient({ username: 'Alex' }); - act(() => emitClient(client)); - expect(result.current).toEqual(client); - }); -}); - -describe('useWebGUIEntity', () => { - it('tracks the interacted entity and clears to null', async () => { - const { useWebGUIEntity } = await import('../src/hooks/useWebGUIEntity'); - const { result } = renderHook(() => useWebGUIEntity()); - expect(result.current).toBeNull(); - - const entity = makeEntity(); - act(() => emitEntity(entity)); - expect(result.current).toEqual(entity); - - act(() => emitEntity(null)); - expect(result.current).toBeNull(); - }); -}); - -describe('useWebGUISelector', () => { - it('selects a slice of the client and returns null before ready', async () => { - const { useWebGUISelector } = await import('../src/hooks/useWebGUISelector'); - const { result } = renderHook(() => - useWebGUISelector((c) => c.username), - ); - expect(result.current).toBeNull(); - - act(() => emitClient(makeClient({ username: 'Herobrine' }))); - expect(result.current).toBe('Herobrine'); - }); - - it('keeps a stable reference when the selected value is equal (Object.is)', async () => { - const { useWebGUISelector } = await import('../src/hooks/useWebGUISelector'); - const { result } = renderHook(() => useWebGUISelector((c) => c.pos)); - - const pos = { x: 1, y: 2, z: 3 }; - act(() => emitClient(makeClient({ pos }))); - const first = result.current; - - // A new client with a different pos object but equal contents; without a - // custom equalFn the reference changes only when the selected object changes. - act(() => emitClient(makeClient({ pos }))); - expect(result.current).toBe(first); // same object reference reused - }); - - it('respects a custom equalFn to dedupe structurally-equal values', async () => { - const { useWebGUISelector } = await import('../src/hooks/useWebGUISelector'); - const eq = (a: { x: number }, b: { x: number }) => a.x === b.x; - const { result } = renderHook(() => - useWebGUISelector((c) => ({ x: c.pos.x }), eq), - ); - - act(() => emitClient(makeClient({ pos: { x: 5, y: 0, z: 0 } }))); - const first = result.current; - act(() => emitClient(makeClient({ pos: { x: 5, y: 99, z: 99 } }))); - expect(result.current).toBe(first); // x unchanged → same reference - }); -}); - -describe('usePostToGame', () => { - it('forwards the payload to window.webgui.postToGame', async () => { - const ns = installWebgui(); - const { usePostToGame } = await import('../src/hooks/usePostToGame'); - const { result } = renderHook(() => usePostToGame()); - - const payload = { channel: 'log' as const, message: 'hi' }; - act(() => result.current(payload)); - expect(ns.postToGame).toHaveBeenCalledWith(payload); - }); - - it('is a no-op (no throw) when not running inside the mod', async () => { - const { usePostToGame } = await import('../src/hooks/usePostToGame'); - const { result } = renderHook(() => usePostToGame()); - expect(() => result.current('noop')).not.toThrow(); - }); -}); - -describe('useCloseGui', () => { - it('calls window.webgui.closeGui', async () => { - const ns = installWebgui(); - const { useCloseGui } = await import('../src/hooks/useCloseGui'); - const { result } = renderHook(() => useCloseGui()); - - act(() => result.current()); - expect(ns.closeGui).toHaveBeenCalledTimes(1); - }); - - it('does not throw outside the mod', async () => { - const { useCloseGui } = await import('../src/hooks/useCloseGui'); - const { result } = renderHook(() => useCloseGui()); - expect(() => result.current()).not.toThrow(); - }); -}); - -describe('useWebGUIToken', () => { - it('returns the token from the URL query string', async () => { - window.history.replaceState({}, '', '/?webgui_token=abc.def'); - const { useWebGUIToken } = await import('../src/hooks/useWebGUIToken'); - const { result } = renderHook(() => useWebGUIToken()); - expect(result.current).toBe('abc.def'); - }); - - it('returns null when the param is absent', async () => { - window.history.replaceState({}, '', '/?other=1'); - const { useWebGUIToken } = await import('../src/hooks/useWebGUIToken'); - const { result } = renderHook(() => useWebGUIToken()); - expect(result.current).toBeNull(); - }); - - it('honours a custom param name', async () => { - window.history.replaceState({}, '', '/?custom_tok=xyz'); - const { useWebGUIToken } = await import('../src/hooks/useWebGUIToken'); - const { result } = renderHook(() => useWebGUIToken('custom_tok')); - expect(result.current).toBe('xyz'); - }); -}); - -describe('useWebGUIEvent', () => { - it('invokes the handler with event detail and cleans up on unmount', async () => { - const { useWebGUIEvent } = await import('../src/hooks/useWebGUIEvent'); - const handler = vi.fn(); - const { unmount } = renderHook(() => - useWebGUIEvent<{ balance: number }>('walletUpdate', handler), - ); - - act(() => { - window.dispatchEvent( - new CustomEvent('webgui:walletUpdate', { detail: { balance: 42 } }), - ); - }); - expect(handler).toHaveBeenCalledWith({ balance: 42 }); - - unmount(); - act(() => { - window.dispatchEvent( - new CustomEvent('webgui:walletUpdate', { detail: { balance: 99 } }), - ); - }); - expect(handler).toHaveBeenCalledTimes(1); // no call after unmount - }); -}); diff --git a/test/runCommand.test.ts b/test/runCommand.test.ts deleted file mode 100644 index 3432aa4..0000000 --- a/test/runCommand.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { installWebgui } from './helpers'; -import { runCommand } from '../src/runCommand'; - -describe('runCommand', () => { - beforeEach(() => { - delete (window as unknown as { webgui?: unknown }).webgui; - }); - - it('posts the command on the command channel', () => { - const ns = installWebgui(); - runCommand('give @s minecraft:diamond 1'); - - expect(ns.postToGame).toHaveBeenCalledTimes(1); - expect(ns.postToGame.mock.calls[0][0]).toEqual({ - channel: 'command', - command: 'give @s minecraft:diamond 1', - }); - }); - - it('is a no-op when not running in the mod', () => { - expect(() => runCommand('spawn')).not.toThrow(); - }); -}); diff --git a/test/setup.ts b/test/setup.ts deleted file mode 100644 index e920dcf..0000000 --- a/test/setup.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { afterEach, vi } from 'vitest'; -import { cleanup } from '@testing-library/react'; -import type { WebGUINamespace } from '../src/types'; - -// Reset the DOM/global state and the module registry between tests so the -// store modules (which read window.webgui at import time) start clean. -afterEach(() => { - cleanup(); - delete (window as unknown as { webgui?: WebGUINamespace }).webgui; - vi.resetModules(); -}); diff --git a/test/store.test.ts b/test/store.test.ts deleted file mode 100644 index 9d35a1d..0000000 --- a/test/store.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { installWebgui, makeClient, emitClient } from './helpers'; - -// store.ts reads window.webgui.client and registers its event listener at -// import time, so each test imports it fresh via dynamic import after the -// desired window state is set up. setup.ts calls vi.resetModules() afterEach. -async function loadStore() { - return import('../src/store'); -} - -describe('store', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('starts with a null snapshot when nothing was pre-injected', async () => { - const store = await loadStore(); - expect(store.getSnapshot()).toBeNull(); - }); - - it('captures a client value injected before import (SSR/pre-bootstrap)', async () => { - const client = makeClient({ username: 'Alex' }); - installWebgui({ client }); - const store = await loadStore(); - expect(store.getSnapshot()).toEqual(client); - }); - - it('updates the snapshot when a webgui:client event fires', async () => { - const store = await loadStore(); - const next = makeClient({ username: 'Notch' }); - emitClient(next); - expect(store.getSnapshot()).toEqual(next); - }); - - it('notifies subscribers on change and stops after unsubscribe', async () => { - const store = await loadStore(); - const listener = vi.fn(); - const unsub = store.subscribe(listener); - - emitClient(makeClient({ username: 'One' })); - expect(listener).toHaveBeenCalledTimes(1); - - unsub(); - emitClient(makeClient({ username: 'Two' })); - expect(listener).toHaveBeenCalledTimes(1); // no further calls - }); - - it('getServerSnapshot always returns null (SSR-safe)', async () => { - const store = await loadStore(); - expect(store.getServerSnapshot()).toBeNull(); - }); -}); diff --git a/test/utils.test.ts b/test/utils.test.ts deleted file mode 100644 index a61c790..0000000 --- a/test/utils.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isInMod, isReady } from '../src/utils'; -import { installWebgui, makeClient } from './helpers'; - -describe('isInMod', () => { - it('is false in a normal browser tab (no window.webgui)', () => { - expect(isInMod()).toBe(false); - }); - - it('is true once the mod injected window.webgui', () => { - installWebgui(); - expect(isInMod()).toBe(true); - }); -}); - -describe('isReady', () => { - it('is false for null and undefined', () => { - expect(isReady(null)).toBe(false); - expect(isReady(undefined)).toBe(false); - }); - - it('is true for an actual client object', () => { - expect(isReady(makeClient())).toBe(true); - }); - - it('is true even for falsy-but-present values (0, "", false)', () => { - expect(isReady(0)).toBe(true); - expect(isReady('')).toBe(true); - expect(isReady(false)).toBe(true); - }); -}); diff --git a/tsup.config.ts b/tsup.config.ts index a17557f..859d17b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ splitting: false, sourcemap: true, clean: true, - external: ['react'], + external: ['react', '@webgui/client'], treeshake: true, }); diff --git a/vitest.config.ts b/vitest.config.ts index 1e8f7e0..26359d7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,10 +4,6 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.{ts,tsx}'], - // Each test file gets a fresh module registry so the side-effectful - // store modules (store.ts / entityStore.ts) re-run their init on import. - restoreMocks: true, + include: ['test/**/*.test.ts'], }, });