Merge remote-tracking branch 'origin/master' into fix/checkout-workdir-prompt
# Conflicts: # packages/ui/app-boot/README.i18n.yaml # packages/ui/app-boot/README.md # packages/ui/app-boot/README.zh.md
This commit is contained in:
@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
|
||||
it('does not report an activation rejection shared by entries in the boot audit', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('assembled activation failure')
|
||||
const audit = assertEntriesActivated({
|
||||
loader: {
|
||||
entries: () => ['broken-a', 'broken-b'].map(name => ({
|
||||
options: { name },
|
||||
fiber: {
|
||||
state: 3,
|
||||
inject: {},
|
||||
ctx: { get: () => undefined },
|
||||
await: async () => { throw error },
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as unknown as Context, NAME)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written).toEqual([])
|
||||
expect(proc.exits).toEqual([])
|
||||
await expect(audit).rejects.toThrow('assembled activation failure')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
@@ -157,6 +184,116 @@ describe('assertEntriesLoaded', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesActivated', () => {
|
||||
interface FakeFiber {
|
||||
state: number
|
||||
inject: Record<string, unknown>
|
||||
ctx: { get(name: string): unknown }
|
||||
await(): Promise<unknown>
|
||||
}
|
||||
|
||||
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
|
||||
loader: { entries: () => entries },
|
||||
}) as unknown as Context
|
||||
|
||||
const fiber = (
|
||||
state: number,
|
||||
error?: unknown,
|
||||
inject: Record<string, unknown> = {},
|
||||
services: string[] = [],
|
||||
): FakeFiber => ({
|
||||
state,
|
||||
inject,
|
||||
ctx: { get: name => services.includes(name) ? {} : undefined },
|
||||
await: error === undefined ? async () => undefined : async () => { throw error },
|
||||
})
|
||||
|
||||
it('passes active entries and ignores disabled entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const active = fiber(2)
|
||||
active.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
const disabled = fiber(3, new Error('disabled failure'))
|
||||
disabled.await = async () => {
|
||||
awaitCalls++
|
||||
throw new Error('disabled failure')
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: active, options: { name: 'active' } },
|
||||
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
|
||||
]), NAME)).resolves.toBeUndefined()
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
|
||||
const original = new Error('actual plugin failure')
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
|
||||
})
|
||||
|
||||
it('formats stackless and non-Error activation failures', async () => {
|
||||
const stackless = new Error('stackless failure')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
|
||||
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
|
||||
})
|
||||
|
||||
it('reports unresolved services for pending entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const expected = [
|
||||
`${NAME}: 3 entries did not activate`,
|
||||
'waiting: pending (waiting for services: missingA, missingB)',
|
||||
'single-wait: pending (waiting for service: missing)',
|
||||
'unknown-wait: pending (waiting for services: unknown)',
|
||||
].join('\n')
|
||||
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
|
||||
const singleWait = fiber(0, undefined, { missing: {} })
|
||||
const unknownWait = fiber(0)
|
||||
for (const item of [waiting, singleWait, unknownWait]) {
|
||||
item.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: waiting, options: { name: 'waiting' } },
|
||||
{ fiber: singleWait, options: { name: 'single-wait' } },
|
||||
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
|
||||
]), NAME)).rejects.toThrow(expected)
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the numeric diagnostic for a settled unexpected state', async () => {
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(4), options: { name: 'disposed' } },
|
||||
]), NAME)).rejects.toThrow('disposed: fiber state 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOverlayPatches', () => {
|
||||
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
|
||||
const dir = tmp()
|
||||
const valid = join(dir, 'valid.yml')
|
||||
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
|
||||
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
|
||||
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
|
||||
const malformed = join(dir, 'malformed.yml')
|
||||
writeFileSync(malformed, ': bad')
|
||||
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
|
||||
const mapping = join(dir, 'mapping.yml')
|
||||
writeFileSync(mapping, 'id: target\n')
|
||||
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, '- scalar\n')
|
||||
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot', () => {
|
||||
it('boots a leaf config through the real Loader and settles the tree', async () => {
|
||||
const dir = tmp()
|
||||
@@ -176,7 +313,11 @@ describe('boot', () => {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const prepared: Context[] = []
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
|
||||
expect(hostCtx.loader).toBeDefined()
|
||||
expect([...hostCtx.loader.entries()]).toEqual([])
|
||||
prepared.push(hostCtx)
|
||||
})
|
||||
try {
|
||||
expect(prepared).toEqual([ctx])
|
||||
} finally {
|
||||
@@ -184,11 +325,68 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes dshHomePath to Loader config expressions', async () => {
|
||||
const dir = tmp()
|
||||
const dshHome = join(dir, 'home')
|
||||
vi.stubEnv('DSH_HOME', dshHome)
|
||||
writeFileSync(join(dir, 'capture.mjs'), [
|
||||
'export const name = "capture"',
|
||||
'export function apply(ctx, config) {',
|
||||
' ctx.provide("capturedPath", config.path)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: capture',
|
||||
' name: ./capture.mjs',
|
||||
' config:',
|
||||
" path: !!js dshHomePath('sessions')",
|
||||
'',
|
||||
].join('\n'))
|
||||
let ctx: Context | undefined
|
||||
try {
|
||||
ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions'))
|
||||
} finally {
|
||||
await ctx?.fiber.dispose()
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
|
||||
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
|
||||
// fiber, which lands while boot() is still awaiting the Loader whenever the
|
||||
// surface renders before the last entry settles. The Loader service goes
|
||||
// with the tree, so reading it for the post-boot assertions would crash an
|
||||
// app that exited exactly as the user asked.
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'exiting.mjs'), [
|
||||
'export const name = "exiting"',
|
||||
'export function apply(ctx) {',
|
||||
' void ctx.root.fiber.dispose()',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
expect(ctx.get('loader')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
|
||||
`${NAME}: 1 entry did not activate`,
|
||||
'./waiting.mjs: pending (waiting for service: neverProvided)',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* `renderConfigDump` behavior: the offline composition must equal what
|
||||
* `boot()` mounts (same parser, same patch algorithm), print `!!js`
|
||||
* expressions verbatim, separate provenance runs with comment lines while
|
||||
* staying one loadable YAML document, and report skipped patches through
|
||||
* `warn` instead of failing — mirroring the Loader's boot-time warning for a
|
||||
* shared overlay whose row exists only on another surface.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
|
||||
|
||||
function writeBase(dir: string): string {
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: shared',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: base',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- id: untouched',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
return base
|
||||
}
|
||||
|
||||
describe('renderConfigDump', () => {
|
||||
it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const surface = join(dir, 'surface.yml')
|
||||
writeFileSync(surface, [
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' key: !!js process.env.DSH_DUMP_SPEC',
|
||||
'- insert:',
|
||||
' - id: surface-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const personal = join(dir, 'personal.yml')
|
||||
writeFileSync(personal, [
|
||||
'- id: surface-extra',
|
||||
' config:',
|
||||
' value: personal',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{ label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) },
|
||||
{ label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) },
|
||||
], () => {})
|
||||
// Comments do not break loadability: the dump parses as one document
|
||||
// equal to what boot() would mount.
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
id: string
|
||||
config?: Record<string, unknown>
|
||||
}[]
|
||||
expect(parsed).toEqual([
|
||||
{
|
||||
id: 'shared',
|
||||
name: './noop.mjs',
|
||||
config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } },
|
||||
},
|
||||
{ id: 'untouched', name: './noop.mjs' },
|
||||
{ id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } },
|
||||
])
|
||||
// Unevaluated: the expression text round-trips as a !!js scalar.
|
||||
expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC')
|
||||
// Provenance separators: origin file, plus every layer that changed the
|
||||
// row; an inserted row carries the inserting layer as its origin.
|
||||
expect(dump).toContain('# == base.yml, patched by surface.yml')
|
||||
expect(dump).toContain('# == base.yml\n- id: untouched')
|
||||
expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra')
|
||||
expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched'))
|
||||
})
|
||||
|
||||
it('groups contiguous same-provenance rows under one separator', () => {
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: a',
|
||||
' name: ./noop.mjs',
|
||||
'- id: b',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const dump = renderConfigDump(NAME, base, [], () => {})
|
||||
expect(dump.match(/# == base\.yml/g)).toHaveLength(1)
|
||||
expect(dump).toContain('# == base.yml\n- id: a')
|
||||
})
|
||||
|
||||
it('composes all layers as one flattened patch list, exactly like boot()', () => {
|
||||
// boot() flattens every layer into ONE applyEntryPatches call, whose id
|
||||
// index sees inserted rows but NOT children introduced by a plain group
|
||||
// `config` replacement. A per-layer composition would rebuild the index
|
||||
// between layers and let the second layer patch that child — a tree the
|
||||
// real boot never mounts. Pin the single-call semantics: the child patch
|
||||
// is skipped (with the layer-labeled warning), matching boot.
|
||||
const dir = tmp()
|
||||
const base = join(dir, 'base.yml')
|
||||
writeFileSync(base, [
|
||||
'- id: g',
|
||||
' name: ./group.mjs',
|
||||
' group: true',
|
||||
' config: []',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(NAME, base, [
|
||||
{
|
||||
label: 'a.yml',
|
||||
patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }],
|
||||
},
|
||||
{ label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] },
|
||||
], line => void warnings.push(line))
|
||||
expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as {
|
||||
config?: { config?: { v?: number } }[]
|
||||
}[]
|
||||
expect(parsed[0]?.config?.[0]?.config?.v).toBe(1)
|
||||
// The skipped layer did not change the row, so it is not in provenance.
|
||||
expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g')
|
||||
expect(dump).not.toContain('b.yml\n- id: g')
|
||||
})
|
||||
|
||||
it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const overlay = join(dir, 'overlay.yml')
|
||||
writeFileSync(overlay, [
|
||||
'- id: only-on-another-surface',
|
||||
' config:',
|
||||
' value: ignored',
|
||||
'- id: shared',
|
||||
' config:',
|
||||
' value: patched',
|
||||
'',
|
||||
].join('\n'))
|
||||
const warnings: string[] = []
|
||||
const dump = renderConfigDump(
|
||||
NAME, base,
|
||||
[{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }],
|
||||
line => void warnings.push(line),
|
||||
)
|
||||
expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`])
|
||||
const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[]
|
||||
expect(parsed[0]?.config?.value).toBe('patched')
|
||||
})
|
||||
|
||||
it('defaults its warn sink to one stderr line per skipped patch', () => {
|
||||
const dir = tmp()
|
||||
const base = writeBase(dir)
|
||||
const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }])
|
||||
expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a missing, unparsable, or non-array base config', () => {
|
||||
const dir = tmp()
|
||||
expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read config `))
|
||||
const invalid = join(dir, 'invalid.yml')
|
||||
writeFileSync(invalid, 'invalid: [unclosed\n')
|
||||
expect(() => renderConfigDump(NAME, invalid, [], () => {}))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse config `))
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, 'id: not-a-list\n')
|
||||
expect(() => renderConfigDump(NAME, scalar, [], () => {}))
|
||||
.toThrow('must be a top-level YAML array of entries')
|
||||
})
|
||||
})
|
||||
@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The surface/`--config`/personal composition: `dsh` includes one shared
|
||||
// base and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted —
|
||||
// otherwise every surface-only row (the whole TUI front door) would be
|
||||
// invisible to the user's `~/.dsh/config.yaml`.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a surface overlay): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' - insert:',
|
||||
' - id: surface-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: surface-default',
|
||||
' - id: surface-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: surface-kept',
|
||||
' config:',
|
||||
' value: personal',
|
||||
' - id: surface-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
|
||||
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user