Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'",
Expand Down
59 changes: 59 additions & 0 deletions test/cache-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FigbirdDevtoolsPanel collector={collector} cacheEditor={editor} />)
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')
})
19 changes: 18 additions & 1 deletion test/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void) => Promise<void>
$: (sel: string) => Element | null
$all: (sel: string) => Element[]
Expand All @@ -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)

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 => {
Expand Down
185 changes: 34 additions & 151 deletions test/figbird-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 => {
Expand Down
35 changes: 35 additions & 0 deletions test/fixtures/multi-service-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type ServiceItem,
type ServiceNames,
type ServicePaths,
type ServiceQuery,
} from '../../lib'

// Test multi-service schema type inference with distinct types
Expand All @@ -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 {
Expand Down Expand Up @@ -109,6 +119,31 @@ export type TaskServiceByName = AppSchema['services']['tasks']
export type PersonServiceItemByName = ServiceItem<AppSchema, 'people'>
export type PersonServiceItem = ServiceDefinitionByPath<AppSchema, 'api/people'>['item']
export type TaskServiceItem = ServiceDefinitionByPath<AppSchema, 'api/tasks'>['item']
export type PersonItemIsPreserved = Assert<Equal<PersonServiceItem, Person>>
export type TaskQueryIsPreserved = Assert<Equal<ServiceQuery<AppSchema, 'tasks'>, 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')
Expand Down
Loading
Loading