import {createRemoteRoot, isRemoteText} from '@remote-ui/core'; import type {RemoteRoot, RemoteChild} from '@remote-ui/core'; import {nodeName, nodeChildToString} from './print'; import {Node, RootNode} from './types'; const IS_NODE = Symbol.for('RemoteUiTesting.Node'); export function createTestRoot() { return createRemoteRoot(() => {}); } export function mount = RemoteRoot>( run: (root: Root) => void, root: Root = createTestRoot() as any, ) { let acting = false; let mounted = true; let rootNode!: Node; act(() => { run(root); }); const rootApi: RootNode = new Proxy( { act, unmount, }, { get(target, key, receiver) { if (Reflect.ownKeys(target).includes(key)) { return Reflect.get(target, key, receiver); } return withRootNode((rootNode) => Reflect.get(rootNode, key)); }, }, ) as any; return rootApi; function createNode({ type, props, instance, children, }: Pick< Node, 'type' | 'props' | 'instance' | 'children' >): Node { const descendants = children.flatMap(getDescendants); function getDescendants(child: typeof children[number]): typeof children { return [ child, ...(typeof child === 'string' ? [] : child.children.flatMap(getDescendants)), ]; } const find: Node['find'] = (type, props) => (descendants.find( (element) => isNode(element) && element.type === type && (props == null || equalSubset(props, element.props as Record)), ) as any) ?? null; const node: Node & {[IS_NODE]: true} = { [IS_NODE]: true, type, props, instance, get text() { return children.reduce( (text, child) => `${text}${typeof child === 'string' ? child : child.text}`, '', ); }, prop: (key) => props[key], is: (checkType) => type === checkType, find, findAll: (type, props) => descendants.filter( (element) => isNode(element) && element.type === type && (props == null || equalSubset(props, element.props as Record)), ) as any, findWhere: (predicate) => (descendants.find( (element) => isNode(element) && predicate(element), ) as any) ?? null, findAllWhere: (predicate) => descendants.filter( (element) => isNode(element) && predicate(element), ) as any, trigger: (prop, ...args) => act( () => { const propValue = props[prop]; if (propValue == null) { throw new Error( `Attempted to call prop ${String( prop, )} but it was not defined.`, ); } return (propValue as any)(...(args as any[])); }, {eager: true}, ), triggerKeypath: (keypath: string, ...args: unknown[]) => act( () => { const parts = keypath.split(/[.[\]]/g).filter(Boolean); let currentProp: any = props; const currentKeypath: string[] = []; for (const part of parts) { if (currentProp == null || typeof currentProp !== 'object') { throw new Error( `Attempted to access field keypath '${currentKeypath.join( '.', )}', but it was not an object.`, ); } currentProp = currentProp[part]; currentKeypath.push(part); } if (typeof currentProp !== 'function') { throw new Error( `Value at keypath '${keypath}' is not a function.`, ); } return currentProp(...args); }, {eager: true}, ), children, descendants, debug: (options) => nodeChildToString(node, options), toString: () => `<${nodeName(node)} />`, }; return node; } function unmount() { if (!mounted) { throw new Error( 'You attempted to unmount a node that was already unmounted', ); } mounted = false; } // Currently, we run the actions directly, so act isn’t actually flushing // updates or anything like that. In the future, we could use the fact that // we force all actions to be nested in here to make other guarantees about // how the updates have been flushed to the passed `root` (right now, we // treat that root as a "noop"). function act(action: () => T, {update = true, eager = false} = {}): T { const performUpdate = update ? updateRootNode : noop; if (acting) { return action(); } acting = true; const afterResolve = () => { performUpdate(); acting = false; return result; }; const result = action(); if (isPromise(result)) { if (eager) { performUpdate(); return act(() => Promise.resolve(result).then(() => {})).then( afterResolve, ) as any; } else { return Promise.resolve(result).then(afterResolve) as any; } } return afterResolve(); } function createNodeFromRemoteChild( child: RemoteChild, ): Node['children'][number] { return isRemoteText(child) ? child.text : createNode({ type: child.type, props: {...(child.props as any)}, instance: child, children: child.children.map(createNodeFromRemoteChild), }); } function updateRootNode() { rootNode = createNode({ type: null, props: {}, children: root.children.map((child) => createNodeFromRemoteChild(child as any), ), instance: root, }); } function withRootNode(perform: (node: Node) => T) { if (!mounted) { throw new Error( 'Attempted to operate on a mounted tree, but it is not mounted. Did you forget to call .mount()? If not, have you already called .unmount()?', ); } return perform(rootNode!); } } export function isNode( maybeNode: unknown, ): maybeNode is Node { return maybeNode != null && (maybeNode as any)[IS_NODE]; } function isPromise(promise: unknown): promise is Promise { return typeof (promise as any)?.then === 'function'; } function equalSubset( subset: Record, full: Record, ) { return Object.keys(subset).every( (key) => key in full && full[key] === subset[key], ); } function noop() {}