diff --git a/package.json b/package.json index 409004a5..4683bc7f 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "tsconfig.json" ], "scripts": { - "test": "npm run tsc && oxlint -c oxlintrc.json && oxfmt --check '**/*.{ts,tsx,js,jsx,css,yml}' && c8 ava", - "lint": "oxlint -c oxlintrc.json", + "test": "npm run tsc && npm run lint && oxfmt --check '**/*.{ts,tsx,js,jsx,css,yml}' && c8 ava", + "lint": "oxlint -c oxlintrc.json --deny-warnings", "tsc": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/figbird/tsconfig.tsbuildinfo", "ava": "ava", "format": "oxfmt --write '**/*.{ts,tsx,js,jsx,css,yml}'", diff --git a/test/cache-editor.test.tsx b/test/cache-editor.test.tsx new file mode 100644 index 00000000..63038fcb --- /dev/null +++ b/test/cache-editor.test.tsx @@ -0,0 +1,59 @@ +import { createCollector } from '../lib/devtools/collector.js' +import { FigbirdDevtoolsPanel } from '../lib/devtools/Devtools.js' +import { dom, it } from './dom.js' +import { createTestApp } from './helpers.js' +import { schema, services } from './mutation-test-helpers.js' + +it('cache editor validates IDs, updates query membership, and undoes edits without server writes', async t => { + const { figbird, feathers } = createTestApp(schema, services(), { queryAwareFind: true }) + const { render, unmount, click, input, flush, $, $all } = dom() + t.teardown(() => { + unmount() + figbird.dispose() + }) + const list = figbird.query(figbird.q.notes) + const detail = figbird.query(figbird.q.notes.get(1)) + const filtered = figbird.query(figbird.q.notes.where({ content: 'hello' })) + for (const query of [list, detail, filtered]) t.teardown(query.subscribe(() => {})) + await Promise.all([list.suspensePromise(), detail.suspensePromise(), filtered.suspensePromise()]) + const counts = { ...feathers.service('notes').counts } + const collector = createCollector(figbird, { heartbeatMs: 0 }) + const editor = { + update: async (serviceName: string, itemId: string | number, item: unknown) => + figbird.editCacheEntity(serviceName, itemId, item), + } + const button = (label: string) => $all('button').find(el => el.textContent === label)! + + render() + click(button('cache')) + t.is($all('tbody tr[tabindex]').length, 2) + click($all('tbody tr[tabindex]').find(row => row.textContent?.includes('hello'))!) + click(button('Edit cache')) + + input($('textarea')!, JSON.stringify({ id: 2, content: 'edited' })) + await flush(() => click(button('Apply in memory'))) + t.true($('body')?.textContent?.includes('Edited JSON must retain the entity ID')) + t.is(detail.getSnapshot().data?.content, 'hello') + t.is(filtered.getSnapshot().data?.length, 1) + t.truthy($('textarea'), 'a rejected edit stays open for correction') + + input($('textarea')!, JSON.stringify({ id: 1, content: 'edited' })) + await flush(() => click(button('Apply in memory'))) + t.is(detail.getSnapshot().data?.content, 'edited') + t.is(list.getSnapshot().data?.find(note => note.id === 1)?.content, 'edited') + t.deepEqual(filtered.getSnapshot().data, []) + t.true($('body')?.textContent?.includes('Applied in memory. No server request was sent.')) + t.is($('textarea'), null) + t.is(collector.getSnapshot().cache?.[0]?.entities[0]?.lastChange?.source, 'devtools') + + await flush(() => click(button('Undo'))) + t.is(detail.getSnapshot().data?.content, 'hello') + t.is(list.getSnapshot().data?.find(note => note.id === 1)?.content, 'hello') + t.deepEqual( + filtered.getSnapshot().data?.map(note => note.id), + [1], + ) + t.false($all('button').some(el => el.textContent === 'Undo')) + t.deepEqual(feathers.service('notes').counts, counts, 'edits and undo send no requests') + t.is(feathers.service('notes').data[1]?.content, 'hello') +}) diff --git a/test/dom.ts b/test/dom.ts index 14435ee3..9fcf8ba4 100644 --- a/test/dom.ts +++ b/test/dom.ts @@ -18,6 +18,7 @@ interface DomHelpers { render: (el: ReactElement) => void unmount: () => void click: (el: Element) => void + input: (el: Element, value: string) => void flush: (fn?: () => Promise | void) => Promise $: (sel: string) => Element | null $all: (sel: string) => Element[] @@ -32,6 +33,7 @@ export function dom(options?: RootOptions): DomHelpers { // This is necessary because JSDOM provides its own DOMWindow type that has slight differences // from the standard Window interface, but is functionally compatible for testing purposes. global.window = dom.window as unknown as Window & typeof globalThis + global.document = dom.window.document const domNode = dom.window.document.getElementById('root')! const root = createRoot(domNode, options) @@ -67,6 +69,21 @@ export function dom(options?: RootOptions): DomHelpers { }) } + function input(el: Element, value: string): void { + const prototype = + el instanceof dom.window.HTMLTextAreaElement + ? dom.window.HTMLTextAreaElement.prototype + : el instanceof dom.window.HTMLInputElement + ? dom.window.HTMLInputElement.prototype + : undefined + const setter = prototype && Object.getOwnPropertyDescriptor(prototype, 'value')?.set + if (!setter) throw new Error('Expected a text input or textarea') + act(() => { + setter.call(el, value) + el.dispatchEvent(new dom.window.Event('input', { bubbles: true })) + }) + } + function $(sel: string): Element | null { return dom.window.document.querySelector(sel) } @@ -84,7 +101,7 @@ export function dom(options?: RootOptions): DomHelpers { }) } - return { root, render, unmount, click, flush, $, $all, act } + return { root, render, unmount, click, input, flush, $, $all, act } } export const swallowErrors = (yourTestFn: () => void): void => { diff --git a/test/figbird-instance.test.ts b/test/figbird-instance.test.ts index 02e1d1d2..0b63bb42 100644 --- a/test/figbird-instance.test.ts +++ b/test/figbird-instance.test.ts @@ -1,4 +1,4 @@ -import { TestClock } from './clock.js' +import { flushTasks, TestClock } from './clock.js' import test from 'ava' import { FeathersAdapter } from '../lib/adapters/feathers' import { Figbird } from '../lib/core/figbird' @@ -179,162 +179,45 @@ test('Figbird instance retains idle queries and releases owned resources on disp t.throws(() => new Figbird({ adapter, schema, gcTime: -1 }), { message: /gcTime/ }) }) -test('figbird.query with get returns typed data', async t => { +test('queryDesc reads each service independently with a schema', async t => { + const note = { id: 1, content: 'hello' } + const post = { id: 1, title: 'post title', body: 'post body' } const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - posts: { - data: { - 1: { id: 1, title: 'post title', body: 'post body' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) - - const query = figbird.queryDesc({ serviceName: 'notes', method: 'get', resourceId: 1 }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) - - t.deepEqual(result, { id: 1, content: 'hello' }) -}) - -test('figbird.query with find returns typed data', async t => { - const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - posts: { - data: { - 1: { id: 1, title: 'post title', body: 'post body' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) - - const query = figbird.queryDesc({ serviceName: 'notes', method: 'find' }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) - - t.deepEqual(result, [{ id: 1, content: 'hello' }]) -}) - -test('figbird.query with get returns typed data for the second service', async t => { - const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - posts: { - data: { - 1: { id: 1, title: 'post title', body: 'post body' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) - - const query = figbird.queryDesc({ serviceName: 'posts', method: 'get', resourceId: 1 }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) - - t.deepEqual(result, { id: 1, title: 'post title', body: 'post body' }) -}) - -test('figbird.query with find returns typed data for the second service', async t => { - const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - posts: { - data: { - 1: { id: 1, title: 'post title', body: 'post body' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ schema, adapter }) - - const query = figbird.queryDesc({ serviceName: 'posts', method: 'find' }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) - - t.deepEqual(result, [{ id: 1, title: 'post title', body: 'post body' }]) -}) - -test('figbird.query with get returns any data when no schema is provided', async t => { - const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ adapter }) - - const query = figbird.queryDesc({ serviceName: 'notes', method: 'get', resourceId: 1 }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) + notes: { data: { 1: note } }, + posts: { data: { 1: post } }, + }) + const figbird = new Figbird({ schema, adapter: new FeathersAdapter(feathers) }) + t.teardown(() => figbird.dispose()) + + const noteGet = figbird.queryDesc({ serviceName: 'notes', method: 'get', resourceId: 1 }) + const noteFind = figbird.queryDesc({ serviceName: 'notes', method: 'find' }) + const postGet = figbird.queryDesc({ serviceName: 'posts', method: 'get', resourceId: 1 }) + const postFind = figbird.queryDesc({ serviceName: 'posts', method: 'find' }) + for (const query of [noteGet, noteFind, postGet, postFind]) { + t.teardown(query.subscribe(() => {})) + } + await flushTasks() - t.deepEqual(result, { id: 1, content: 'hello' }) + t.deepEqual(noteGet.getSnapshot()?.data, note) + t.deepEqual(noteFind.getSnapshot()?.data, [note]) + t.deepEqual(postGet.getSnapshot()?.data, post) + t.deepEqual(postFind.getSnapshot()?.data, [post]) }) -test('figbird.query with find returns any data when no schema is provided', async t => { - const feathers = mockFeathers({ - notes: { - data: { - 1: { id: 1, content: 'hello' }, - }, - }, - }) - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ adapter }) +test('queryDesc supports get and find without a schema', async t => { + const note = { id: 1, content: 'hello' } + const feathers = mockFeathers({ notes: { data: { 1: note } } }) + const figbird = new Figbird({ adapter: new FeathersAdapter(feathers) }) + t.teardown(() => figbird.dispose()) - const query = figbird.queryDesc({ serviceName: 'notes', method: 'find' }) - const result = await new Promise(resolve => { - query.subscribe(state => { - if (state.status === 'success') { - resolve(state.data) - } - }) - }) + const get = figbird.queryDesc({ serviceName: 'notes', method: 'get', resourceId: 1 }) + const find = figbird.queryDesc({ serviceName: 'notes', method: 'find' }) + t.teardown(get.subscribe(() => {})) + t.teardown(find.subscribe(() => {})) + await flushTasks() - t.deepEqual(result, [{ id: 1, content: 'hello' }]) + t.deepEqual(get.getSnapshot()?.data, note) + t.deepEqual(find.getSnapshot()?.data, [note]) }) test('figbird.mutate with create', async t => { diff --git a/test/fixtures/multi-service-inference.ts b/test/fixtures/multi-service-inference.ts index 4484706f..940a349a 100644 --- a/test/fixtures/multi-service-inference.ts +++ b/test/fixtures/multi-service-inference.ts @@ -11,6 +11,7 @@ import { type ServiceItem, type ServiceNames, type ServicePaths, + type ServiceQuery, } from '../../lib' // Test multi-service schema type inference with distinct types @@ -35,6 +36,15 @@ interface PersonService { interface TaskService { item: Task + query: TaskQuery +} + +interface TaskQuery { + completed?: boolean + priority?: number + assigneeId?: string + $search?: string + $asOf?: Date } interface ApiSchemaTypes { @@ -109,6 +119,31 @@ export type TaskServiceByName = AppSchema['services']['tasks'] export type PersonServiceItemByName = ServiceItem export type PersonServiceItem = ServiceDefinitionByPath['item'] export type TaskServiceItem = ServiceDefinitionByPath['item'] +export type PersonItemIsPreserved = Assert> +export type TaskQueryIsPreserved = Assert, TaskQuery>> + +const asOf = new Date(0) + +export function TaskQueryInferenceFixture() { + useFind('api/tasks', { + query: { completed: false, priority: 1, assigneeId: '123', $search: 'test', $asOf: asOf }, + matcher: query => item => { + const typedQuery: TaskQuery | undefined = query + const typedItem: Task = item + // @ts-expect-error Matcher items retain the task shape. + void item.email + // @ts-expect-error Matcher queries retain the custom query shape. + void query?.invalidProperty + return typedQuery?.completed === undefined || typedItem.completed === typedQuery.completed + }, + }) + // @ts-expect-error Query fields must be declared by the service. + useFind('api/tasks', { query: { invalidProperty: true } }) + // @ts-expect-error Custom query operators retain their value types. + useFind('api/tasks', { query: { $asOf: '2024-01-01' } }) + // @ts-expect-error Query fields retain their value types. + useFind('api/tasks', { query: { completed: 'false' } }) +} // Test the actual hooks - these types will be checked by the test export const people = useFind('api/people') diff --git a/test/loading-helpers.test.tsx b/test/loading-helpers.test.tsx new file mode 100644 index 00000000..7a9360e9 --- /dev/null +++ b/test/loading-helpers.test.tsx @@ -0,0 +1,183 @@ +import { mock } from 'node:test' +import type { ExecutionContext } from 'ava' +import { StrictMode, Suspense } from 'react' +import { DelayedFallback, useDebouncedTransition, useDelayedFlag } from '../lib/index.js' +import { dom, it } from './dom.js' + +function timedDom(t: ExecutionContext) { + const d = dom() + mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }) + t.teardown(() => { + d.unmount() + mock.timers.reset() + }) + return { + ...d, + advance: (ms: number) => d.act(async () => mock.timers.tick(ms)), + } +} + +function pendingContent(label: string) { + let ready = false + let resolve!: () => void + const promise = new Promise(r => { + resolve = r + }) + function Content() { + if (!ready) throw promise + return {label} + } + return { + Content, + finish: () => { + ready = true + resolve() + }, + } +} + +it('useDelayedFlag skips short loads and gives the next load its full delay', async t => { + const { render, advance, $ } = timedDom(t) + function Indicator({ fetching }: { fetching: boolean }) { + const visible = useDelayedFlag(fetching, 100) + return {visible ? 'loading' : 'hidden'} + } + const show = (fetching: boolean) => + render( + + + , + ) + + show(true) + t.is($('.indicator')?.textContent, 'hidden') + await advance(99) + show(false) + await advance(1) + t.is($('.indicator')?.textContent, 'hidden') + + show(true) + await advance(99) + t.is($('.indicator')?.textContent, 'hidden') + await advance(1) + t.is($('.indicator')?.textContent, 'loading') + show(false) + t.is($('.indicator')?.textContent, 'hidden') +}) + +it('useDelayedFlag holds a visible spinner through a resumed load without resetting its minimum', async t => { + const { render, advance, $ } = timedDom(t) + function Indicator({ fetching }: { fetching: boolean }) { + const visible = useDelayedFlag(fetching, 100, 300) + return {visible ? 'loading' : 'hidden'} + } + + render() + await advance(100) + t.is($('.indicator')?.textContent, 'loading') + render() + await advance(150) + t.is($('.indicator')?.textContent, 'loading') + + render() + await advance(150) + t.is($('.indicator')?.textContent, 'loading', 'the previous hide timer was cancelled') + render() + t.is($('.indicator')?.textContent, 'hidden', 'the minimum elapsed during the resumed load') + + render() + await advance(100) + render() + await advance(299) + t.is($('.indicator')?.textContent, 'loading') + await advance(1) + t.is($('.indicator')?.textContent, 'hidden') +}) + +it('useDebouncedTransition commits only the latest value after the current delay', async t => { + const { render, advance, $ } = timedDom(t) + function Search({ value, delay = 100 }: { value: string; delay?: number }) { + const search = useDebouncedTransition(value, delay) + return {search} + } + + render() + t.is($('.search')?.textContent, 'initial') + render() + await advance(50) + render() + await advance(99) + t.is($('.search')?.textContent, 'initial') + await advance(1) + t.is($('.search')?.textContent, 'fig') + + render() + await advance(50) + render() + await advance(199) + t.is($('.search')?.textContent, 'fig') + await advance(1) + t.is($('.search')?.textContent, 'figbird') +}) + +it('useDebouncedTransition keeps committed content visible while the next value suspends', async t => { + const { render, advance, flush, $ } = timedDom(t) + const next = pendingContent('next') + function Search({ value }: { value: string }) { + const search = useDebouncedTransition(value, 100) + return ( + loading}> + {search === 'next' ? : {search}} + + ) + } + + render() + render() + await advance(100) + t.is($('.content')?.textContent, 'initial') + t.is($('.fallback'), null) + await flush(next.finish) + t.is($('.content')?.textContent, 'next') + t.is($('.fallback'), null) +}) + +it('DelayedFallback skips fast Suspense loads and delays each new fallback mount', async t => { + const { render, advance, flush, $ } = timedDom(t) + const fast = pendingContent('fast') + const slow = pendingContent('slow') + const fallback = ( + + loading + + ) + + render( + + + + + , + ) + await advance(249) + t.is($('.fallback'), null) + await flush(fast.finish) + await advance(1) + t.is($('.content')?.textContent, 'fast') + t.is($('.fallback'), null) + + render( + + + + + , + ) + await advance(249) + t.is($('.fallback'), null) + await advance(1) + t.is($('.fallback')?.textContent, 'loading') + await flush(slow.finish) + t.is($('.content')?.textContent, 'slow') + t.is($('.fallback'), null) +}) diff --git a/test/schema.test.tsx b/test/schema.test.tsx index d846fdf3..da0122d2 100644 --- a/test/schema.test.tsx +++ b/test/schema.test.tsx @@ -303,70 +303,6 @@ it('schema-based type inference', t => { }) }) -it('schema with array of services', t => { - const { render, unmount, flush, $ } = dom() - - // Services are defined in an object map - const schema = createSchema({ - services: { - 'api/people': service(), - 'api/tasks': service(), - }, - }) - - const feathers = mockFeathers({ - 'api/people': { - data: { - '1': { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' }, - }, - }, - 'api/tasks': { - data: { - t1: { - id: 't1', - title: 'Test task', - completed: false, - priority: 1, - tags: ['test'], - }, - }, - }, - }) - - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ adapter, schema }) - - // Create typed hooks - const { useFind } = createHooks(schema) - - function App() { - // Use the actual service names - const people = useFind('api/people') - const tasks = useFind('api/tasks') - - return ( - - {people.data?.length ?? 0} people - {tasks.data?.length ?? 0} tasks - - ) - } - - render( - - - , - ) - - return flush().then(() => { - const peopleCount = $('.people-count') - const tasksCount = $('.tasks-count') - t.is(peopleCount?.textContent, '1 people') - t.is(tasksCount?.textContent, '1 tasks') - unmount() - }) -}) - test('schema validates transport paths while materializing declarations', t => { const apiService = service.from<{ 'api/people': PersonService @@ -517,128 +453,3 @@ it('backward compatibility - untyped usage still works', t => { unmount() }) }) - -test('type extraction utilities', t => { - // Test that type extraction utilities work correctly - type PersonItem = import('../lib').ServiceItem - - // These assertions are compile-time only, but we can test runtime behavior - const person: PersonItem = { - id: '1', - name: 'Test', - email: 'test@example.com', - role: 'user', - } - - t.is(person.name, 'Test') - t.is(person.role, 'user') - - // Query type includes custom extensions - type TaskQueryType = import('../lib').ServiceQuery - - const query: TaskQueryType = { - $search: 'test', - $asOf: new Date(), - } as TaskQueryType - - t.truthy(query.$search) - t.truthy(query.$asOf) -}) - -it('query type inference is strict - no extra properties allowed', t => { - const { render, unmount, flush } = dom() - - const feathers = mockFeathers({ - 'api/tasks': { - data: { - t1: { - id: 't1', - title: 'Test task', - completed: false, - priority: 1, - tags: ['test'], - }, - }, - }, - }) - - const adapter = new FeathersAdapter(feathers) - const figbird = new Figbird({ adapter, schema }) - - // Create typed hooks - const { useFind } = createHooks(schema) - - function TestComponent() { - // This should work - valid TaskQuery properties - const validQuery = useFind('api/tasks', { - query: { - completed: false, - $search: 'test', - $asOf: new Date(), - }, - matcher: query => item => { - // Handle custom query operators - if (query?.completed !== undefined && item.completed !== query.completed) { - return false - } - // Custom operators don't affect matching in this test - return true - }, - }) - - // TypeScript should enforce that only defined query properties are allowed - // The following would cause a TypeScript error if uncommented: - // const invalidQuery = useFind('api/tasks', { - // query: { - // completed: false, - // $search: 'test', - // $asOf: new Date(), - // invalidProperty: 'this should not be allowed', // <- This should cause a TS error - // }, - // }) - - // Test that TypeScript correctly types the query parameter - // This should work without errors - all valid properties - const testValidQuery = useFind('api/tasks', { - query: { - completed: false, - priority: 1, - assigneeId: '123', - $search: 'test', - $asOf: new Date(), - }, - matcher: query => item => { - // Handle custom query operators - if (query?.completed !== undefined && item.completed !== query.completed) { - return false - } - if (query?.priority !== undefined && item.priority !== query.priority) { - return false - } - if (query?.assigneeId !== undefined && item.assigneeId !== query.assigneeId) { - return false - } - // Custom operators don't affect matching in this test - return true - }, - }) - - return ( - - {validQuery.data?.length ?? 0} tasks - {testValidQuery.data?.length ?? 0} valid - - ) - } - - render( - - - , - ) - - return flush().then(() => { - t.pass('Query type inference is strict') - unmount() - }) -}) diff --git a/test/setup.ts b/test/setup.ts index a42c376a..7a3ce3eb 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,5 +1,9 @@ -// Make this a module by exporting something -export {} +import { JSDOM } from 'jsdom' + +// React DOM detects input-event support when imported, before individual tests render. +const initialDom = new JSDOM('') +global.window = initialDom.window as unknown as Window & typeof globalThis +global.document = initialDom.window.document // Extend the global namespace to include IS_REACT_ACT_ENVIRONMENT declare global {