mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat: add userdraft listing primitives (#9268)
* feat: add userdraft listing primitives * fix: cancel stale userdraft discard writes * docs: remove global ai userdraft plan
This commit is contained in:
@@ -601,7 +601,7 @@ export function useLocalStorageValue<T>(
|
||||
*/
|
||||
transformBeforePersist?: (val: T) => T
|
||||
}
|
||||
): { val: T; skipNextWriteOnce(): void } {
|
||||
): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } {
|
||||
const saveInitialValue = options?.saveInitialValue ?? true
|
||||
const debounceMs = options?.debounce ?? 0
|
||||
const transformBeforePersist = options?.transformBeforePersist
|
||||
@@ -626,7 +626,9 @@ export function useLocalStorageValue<T>(
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
|
||||
if (typeof window === 'undefined') {
|
||||
return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} }
|
||||
}
|
||||
const savedValue = localStorage.getItem(key)
|
||||
let s = $state<T>(
|
||||
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
|
||||
@@ -662,6 +664,13 @@ export function useLocalStorageValue<T>(
|
||||
pendingValue = undefined
|
||||
}, debounceMs)
|
||||
}
|
||||
const cancelPendingWrite = () => {
|
||||
if (debounceTimer != null) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = undefined
|
||||
}
|
||||
pendingValue = undefined
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
readFieldsRecursively(s)
|
||||
@@ -698,12 +707,19 @@ export function useLocalStorageValue<T>(
|
||||
/**
|
||||
* Arm the persist skip so the next `set val` (or deep-mutation flush)
|
||||
* updates only the in-memory cell and leaves localStorage untouched.
|
||||
* Used by `UserDraft.discard` to reset the in-memory state to a
|
||||
* fallback without re-persisting it — pairs with an explicit LS
|
||||
* delete to leave the slot empty.
|
||||
*/
|
||||
skipNextWriteOnce(): void {
|
||||
skipNextWrite = true
|
||||
},
|
||||
/**
|
||||
* Reset the in-memory state while canceling any queued debounced write.
|
||||
* Used when a caller performs its own synchronous persistence action.
|
||||
*/
|
||||
setWithoutPersist(newVal: T): void {
|
||||
cancelPendingWrite()
|
||||
lastSerialized = newVal === undefined ? undefined : serialize(newVal)
|
||||
skipNextWrite = false
|
||||
s = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
|
||||
export type UserDraftItemKind =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'trigger_schedule'
|
||||
| 'trigger_webhook'
|
||||
| 'trigger_default_email'
|
||||
| 'trigger_email'
|
||||
| 'trigger_http'
|
||||
| 'trigger_websocket'
|
||||
| 'trigger_postgres'
|
||||
| 'trigger_kafka'
|
||||
| 'trigger_nats'
|
||||
| 'trigger_mqtt'
|
||||
| 'trigger_sqs'
|
||||
| 'trigger_gcp'
|
||||
| 'trigger_azure'
|
||||
| 'trigger_poll'
|
||||
| 'trigger_cli'
|
||||
| 'trigger_nextcloud'
|
||||
| 'trigger_google'
|
||||
| 'trigger_github'
|
||||
export const USER_DRAFT_ITEM_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'app',
|
||||
'raw_app',
|
||||
'resource',
|
||||
'variable',
|
||||
'trigger_schedule',
|
||||
'trigger_webhook',
|
||||
'trigger_default_email',
|
||||
'trigger_email',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_postgres',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'trigger_poll',
|
||||
'trigger_cli',
|
||||
'trigger_nextcloud',
|
||||
'trigger_google',
|
||||
'trigger_github'
|
||||
] as const
|
||||
|
||||
export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number]
|
||||
|
||||
export type UserDraftOptions = {
|
||||
workspace?: string
|
||||
@@ -43,6 +46,10 @@ export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: readonly UserDraftItemKind[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
|
||||
* for. The shape mirrors `use()`'s arguments, just bundled into one object
|
||||
@@ -93,10 +100,14 @@ function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefine
|
||||
type DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
skipNextWriteOnce(): void
|
||||
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
count: number
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
state: DraftState<unknown>
|
||||
/**
|
||||
* Tears down the `$effect.root` scope that owns the entry's
|
||||
@@ -109,6 +120,16 @@ type DraftEntry = {
|
||||
destroyRoot?: () => void
|
||||
}
|
||||
|
||||
export type UserDraftEntry<V = unknown> = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
value: V | undefined
|
||||
meta: UserDraftMeta
|
||||
persisted: boolean
|
||||
live: boolean
|
||||
}
|
||||
|
||||
const entries = new Map<string, DraftEntry>()
|
||||
|
||||
function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
@@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
|
||||
return `userdraft/w/${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
function parseLocalStorageKey(
|
||||
key: string,
|
||||
workspace: string,
|
||||
itemKinds: readonly UserDraftItemKind[]
|
||||
): { itemKind: UserDraftItemKind; path: string } | undefined {
|
||||
const prefix = `userdraft/w/${workspace}/`
|
||||
if (!key.startsWith(prefix)) return undefined
|
||||
const rest = key.slice(prefix.length)
|
||||
for (const itemKind of itemKinds) {
|
||||
const kindPrefix = `${itemKind}/`
|
||||
if (rest.startsWith(kindPrefix)) {
|
||||
return { itemKind, path: rest.slice(kindPrefix.length) }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
return structuredClone($state.snapshot(value)) as V
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as V
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
set draft(value: V | undefined)
|
||||
@@ -299,6 +350,27 @@ export const UserDraft = {
|
||||
}
|
||||
},
|
||||
|
||||
setDraftAndMeta<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V | undefined,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
entry.state.val = wrap(value, meta)
|
||||
// Static writes represent explicit external draft mutations. A
|
||||
// freshly acquired live entry may still have the initial-write skip
|
||||
// armed, so force the storage slot to match the live value.
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
return
|
||||
}
|
||||
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
|
||||
},
|
||||
|
||||
/**
|
||||
* Autosave gate: persist `value` only when it differs (after
|
||||
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
|
||||
@@ -396,6 +468,61 @@ export const UserDraft = {
|
||||
}
|
||||
},
|
||||
|
||||
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
||||
UserDraft.discard(itemKind, path, undefined, opts)
|
||||
},
|
||||
|
||||
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
|
||||
const out = new Map<string, UserDraftEntry<V>>()
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
|
||||
}
|
||||
for (const key of keys) {
|
||||
const parsed = parseLocalStorageKey(key, ws, itemKinds)
|
||||
if (!parsed) continue
|
||||
const stored = readPersisted<V>(key)
|
||||
if (stored === undefined) continue
|
||||
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
|
||||
workspace: ws,
|
||||
itemKind: parsed.itemKind,
|
||||
path: parsed.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: true,
|
||||
live: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
|
||||
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
|
||||
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
|
||||
if (stored === undefined) {
|
||||
out.delete(mk)
|
||||
continue
|
||||
}
|
||||
const existing = out.get(mk)
|
||||
out.set(mk, {
|
||||
workspace: entry.workspace,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
persisted: existing?.persisted ?? false,
|
||||
live: true
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(out.values())
|
||||
},
|
||||
|
||||
/**
|
||||
* Like `remove`, but also resets any live handle's `draft` to
|
||||
* `fallback` in-memory (so reactive readers see it immediately) and
|
||||
@@ -412,10 +539,11 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Arm the skip before the cell write so the setter suppresses
|
||||
// the persist; the removeItem below actually clears the slot.
|
||||
entry.state.skipNextWriteOnce()
|
||||
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
|
||||
// Drop any queued debounced write owned by this live entry before
|
||||
// resetting the in-memory value. Otherwise a timer from the old
|
||||
// entry can outlive unmount and later delete a freshly written
|
||||
// draft for the same key.
|
||||
entry.state.setWithoutPersist(wrap(fallback) as StoredDraft<unknown> | undefined)
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(localStorageKey(ws, itemKind, path))
|
||||
@@ -549,7 +677,7 @@ function acquireEntry(
|
||||
)
|
||||
})
|
||||
if (stateRef) {
|
||||
entries.set(mk, { count: 1, state: stateRef, destroyRoot })
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
|
||||
return
|
||||
}
|
||||
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
|
||||
@@ -560,7 +688,7 @@ function acquireEntry(
|
||||
undefined,
|
||||
useLocalStorageOptions
|
||||
)
|
||||
entries.set(mk, { count: 1, state })
|
||||
entries.set(mk, { count: 1, workspace, itemKind, path, state })
|
||||
}
|
||||
|
||||
function releaseEntry(mk: string): void {
|
||||
|
||||
@@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => {
|
||||
expect(storedShape(KEY)).toBe(wrapped(value))
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserDraft.list / clear / setDraftAndMeta', () => {
|
||||
it('enumerates persisted-only drafts for the requested workspace and kinds', () => {
|
||||
UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' })
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
'f/b',
|
||||
{ path: 'f/b', value: { modules: [] } },
|
||||
{ remoteRev: 2 },
|
||||
{ workspace: 'other_ws' }
|
||||
)
|
||||
UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {})
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'f/a',
|
||||
value: { path: 'f/a', content: 'a' },
|
||||
meta: { remoteRev: 'h1' },
|
||||
persisted: true,
|
||||
live: false
|
||||
}
|
||||
])
|
||||
expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([
|
||||
expect.objectContaining({
|
||||
workspace: 'other_ws',
|
||||
itemKind: 'flow',
|
||||
path: 'f/b',
|
||||
persisted: true,
|
||||
live: false
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => {
|
||||
UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {})
|
||||
UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {})
|
||||
UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {})
|
||||
|
||||
const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] })
|
||||
|
||||
expect(entries).toHaveLength(3)
|
||||
expect(entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
itemKind: 'script',
|
||||
path: '',
|
||||
value: { path: '', content: 'scratch' }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
itemKind: 'script',
|
||||
path: 'f/new-a',
|
||||
value: { path: 'f/new-a', content: 'a' }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
itemKind: 'script',
|
||||
path: 'f/new-b',
|
||||
value: { path: 'f/new-b', content: 'b' }
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('enumerates live-only drafts before the debounce persists them', () => {
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live')
|
||||
handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' })
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull()
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'f/live',
|
||||
value: { path: 'f/live', content: 'live' },
|
||||
meta: { remoteRev: 'h1' },
|
||||
persisted: false,
|
||||
live: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes entries that are both persisted and live', () => {
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/both',
|
||||
{ path: 'f/both', content: 'persisted' },
|
||||
{
|
||||
remoteRev: 'h1'
|
||||
}
|
||||
)
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both')
|
||||
handle.draft = { path: 'f/both', content: 'live' }
|
||||
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
{
|
||||
workspace: 'test_ws',
|
||||
itemKind: 'script',
|
||||
path: 'f/both',
|
||||
value: { path: 'f/both', content: 'live' },
|
||||
meta: { remoteRev: 'h1' },
|
||||
persisted: true,
|
||||
live: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('clear removes persisted storage and live state without re-persisting', () => {
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/clear',
|
||||
{ path: 'f/clear', content: 'x' },
|
||||
{
|
||||
remoteRev: 'h1'
|
||||
}
|
||||
)
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear')
|
||||
expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' })
|
||||
|
||||
UserDraft.clear('script', 'f/clear')
|
||||
flushPersist()
|
||||
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull()
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
|
||||
})
|
||||
|
||||
it('clear cancels pending debounced live writes', () => {
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear')
|
||||
handle.draft = { path: 'f/pending-clear', content: 'initial' }
|
||||
handle.draft = { path: 'f/pending-clear', content: 'pending' }
|
||||
|
||||
UserDraft.clear('script', 'f/pending-clear')
|
||||
expect(handle.draft).toBeUndefined()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
|
||||
|
||||
flushPersist()
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
|
||||
})
|
||||
|
||||
it('clear does not let an old debounced remove delete a later direct write', () => {
|
||||
const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear'
|
||||
const handle = UserDraft.use<{ path: string; content: string }>(
|
||||
'script',
|
||||
'f/rewrite-after-clear'
|
||||
)
|
||||
handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' }
|
||||
handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' }
|
||||
|
||||
UserDraft.clear('script', 'f/rewrite-after-clear')
|
||||
flushDestroyCallbacks()
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/rewrite-after-clear',
|
||||
{ path: 'f/rewrite-after-clear', content: 'new' },
|
||||
{}
|
||||
)
|
||||
|
||||
flushPersist()
|
||||
expect(storedShape(key)).toBe(
|
||||
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
|
||||
)
|
||||
})
|
||||
|
||||
it('list hides persisted drafts when a live handle has cleared the value', () => {
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/live-clear',
|
||||
{ path: 'f/live-clear', content: 'persisted' },
|
||||
{}
|
||||
)
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear')
|
||||
handle.draft = { path: 'f/live-clear', content: 'edited' }
|
||||
handle.draft = undefined
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull()
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
|
||||
})
|
||||
|
||||
it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => {
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta')
|
||||
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/meta',
|
||||
{ path: 'f/meta', content: 'first' },
|
||||
{
|
||||
remoteRev: 'h1',
|
||||
remoteDraftRev: 'd1'
|
||||
}
|
||||
)
|
||||
handle.draft = { path: 'f/meta', content: 'second' }
|
||||
|
||||
expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' })
|
||||
expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' })
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
value: { path: 'f/meta', content: 'second' },
|
||||
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('static setDraftAndMeta persists first writes even when a live handle exists', () => {
|
||||
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live')
|
||||
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
'f/static-live',
|
||||
{ path: 'f/static-live', content: 'first' },
|
||||
{ remoteRev: 'h1' }
|
||||
)
|
||||
|
||||
expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' })
|
||||
expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe(
|
||||
JSON.stringify({
|
||||
value: { path: 'f/static-live', content: 'first' },
|
||||
remoteRev: 'h1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('lists live drafts with runtime-only values without throwing', () => {
|
||||
const handle = UserDraft.use<Record<string, unknown>>('script', 'f/runtime')
|
||||
handle.draft = {
|
||||
path: 'f/runtime',
|
||||
content: 'x',
|
||||
callback: () => 'not serializable'
|
||||
}
|
||||
|
||||
expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow()
|
||||
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
|
||||
expect.objectContaining({
|
||||
itemKind: 'script',
|
||||
path: 'f/runtime',
|
||||
value: { path: 'f/runtime', content: 'x' }
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user