|
|
@@ -0,0 +1,587 @@
|
|
|
+import { Context } from 'cordis'
|
|
|
+import { describe, expect, it, vi } from 'vitest'
|
|
|
+import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
+import type {
|
|
|
+ Component,
|
|
|
+ OverlayHandle,
|
|
|
+} from '@earendil-works/pi-tui'
|
|
|
+import type {
|
|
|
+ TuiComponent,
|
|
|
+ TuiOverlayHost,
|
|
|
+ TuiOverlayOptions,
|
|
|
+ TuiOverlaySession,
|
|
|
+ TuiTheme,
|
|
|
+} from '../src/extension.ts'
|
|
|
+import {
|
|
|
+ TuiExtensionServiceImpl,
|
|
|
+ TuiOverlayManager,
|
|
|
+ type TuiOverlayDriver,
|
|
|
+} from '../src/overlay-manager.ts'
|
|
|
+
|
|
|
+const theme: TuiTheme = Object.freeze({
|
|
|
+ text: (value: string) => `text:${value}`,
|
|
|
+ muted: (value: string) => `muted:${value}`,
|
|
|
+ dim: (value: string) => `dim:${value}`,
|
|
|
+ accent: (value: string) => `accent:${value}`,
|
|
|
+ success: (value: string) => `success:${value}`,
|
|
|
+ warning: (value: string) => `warning:${value}`,
|
|
|
+ error: (value: string) => `error:${value}`,
|
|
|
+ bold: (value: string) => `bold:${value}`,
|
|
|
+})
|
|
|
+
|
|
|
+interface ShownOverlay {
|
|
|
+ component: Component
|
|
|
+ options: TuiOverlayOptions | undefined
|
|
|
+ hidden: boolean
|
|
|
+ focused: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface DriverFixture {
|
|
|
+ driver: TuiOverlayDriver
|
|
|
+ shown: ShownOverlay[]
|
|
|
+ errors: unknown[]
|
|
|
+ invalidations: number
|
|
|
+ showError?: unknown
|
|
|
+ onShow?: (component: Component) => void
|
|
|
+}
|
|
|
+
|
|
|
+function driverFixture(): DriverFixture {
|
|
|
+ const fixture: DriverFixture = {
|
|
|
+ shown: [],
|
|
|
+ errors: [],
|
|
|
+ invalidations: 0,
|
|
|
+ driver: undefined as never,
|
|
|
+ }
|
|
|
+ fixture.driver = {
|
|
|
+ viewport: () => ({ columns: 96, rows: 32 }),
|
|
|
+ theme: () => theme,
|
|
|
+ display: value => `safe:${value}`,
|
|
|
+ show(component, options) {
|
|
|
+ if (fixture.showError !== undefined) throw fixture.showError
|
|
|
+ const shown: ShownOverlay = {
|
|
|
+ component,
|
|
|
+ options,
|
|
|
+ hidden: false,
|
|
|
+ focused: true,
|
|
|
+ }
|
|
|
+ fixture.shown.push(shown)
|
|
|
+ const handle: OverlayHandle = {
|
|
|
+ hide() {
|
|
|
+ shown.hidden = true
|
|
|
+ shown.focused = false
|
|
|
+ },
|
|
|
+ setHidden(hidden) {
|
|
|
+ shown.hidden = hidden
|
|
|
+ },
|
|
|
+ isHidden: () => shown.hidden,
|
|
|
+ focus() {
|
|
|
+ shown.focused = true
|
|
|
+ },
|
|
|
+ unfocus() {
|
|
|
+ shown.focused = false
|
|
|
+ },
|
|
|
+ isFocused: () => shown.focused,
|
|
|
+ }
|
|
|
+ fixture.onShow?.(component)
|
|
|
+ return handle
|
|
|
+ },
|
|
|
+ invalidate() {
|
|
|
+ fixture.invalidations += 1
|
|
|
+ },
|
|
|
+ reportError(error) {
|
|
|
+ fixture.errors.push(error)
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return fixture
|
|
|
+}
|
|
|
+
|
|
|
+function component(lines = ['overlay']): TuiComponent {
|
|
|
+ return {
|
|
|
+ render: () => lines,
|
|
|
+ invalidate() {},
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function microtask(): Promise<void> {
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+}
|
|
|
+
|
|
|
+describe('TuiOverlayManager', () => {
|
|
|
+ it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ let firstHost: TuiOverlayHost | undefined
|
|
|
+ const firstComponent = {
|
|
|
+ focused: false,
|
|
|
+ wantsKeyRelease: true,
|
|
|
+ inputs: [] as string[],
|
|
|
+ invalidated: 0,
|
|
|
+ render: (width: number) => [`first:${String(width)}`],
|
|
|
+ handleInput(data: string) {
|
|
|
+ this.inputs.push(data)
|
|
|
+ },
|
|
|
+ invalidate() {
|
|
|
+ this.invalidated += 1
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const first = manager.open({
|
|
|
+ create(host) {
|
|
|
+ firstHost = host
|
|
|
+ return firstComponent
|
|
|
+ },
|
|
|
+ options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
|
|
|
+ })
|
|
|
+ const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
|
|
|
+ const second = manager.open({
|
|
|
+ create: () => component(['second']),
|
|
|
+ options: secondOptions,
|
|
|
+ })
|
|
|
+ ;(secondOptions as { width: number }).width = 80
|
|
|
+ ;(secondOptions.margin as { bottom: number }).bottom = 4
|
|
|
+
|
|
|
+ expect(manager.hasActiveOverlay()).toBe(true)
|
|
|
+ expect(first.state).toBe('active')
|
|
|
+ expect(second.state).toBe('queued')
|
|
|
+ expect(fixture.shown).toHaveLength(1)
|
|
|
+ expect(fixture.shown[0]?.options).toEqual({
|
|
|
+ width: '75%',
|
|
|
+ minWidth: 24,
|
|
|
+ maxHeight: 20,
|
|
|
+ anchor: 'center',
|
|
|
+ margin: { bottom: 1 },
|
|
|
+ })
|
|
|
+ expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
|
|
|
+ expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
|
|
|
+ expect(firstHost?.theme.accent('x')).toBe('accent:x')
|
|
|
+ expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
|
|
|
+ firstHost?.invalidate()
|
|
|
+ expect(firstComponent.invalidated).toBe(1)
|
|
|
+ expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
|
|
|
+ fixture.shown[0]!.component.handleInput?.('x')
|
|
|
+ fixture.shown[0]!.component.invalidate()
|
|
|
+ expect(firstComponent.inputs).toEqual(['x'])
|
|
|
+ expect(firstComponent.invalidated).toBe(2)
|
|
|
+ expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
|
|
|
+ ;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
|
|
|
+ expect(firstComponent.focused).toBe(true)
|
|
|
+ expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
|
|
|
+
|
|
|
+ const firstOutcome = await first.close()
|
|
|
+ expect(firstOutcome).toEqual({ reason: 'closed' })
|
|
|
+ expect(await first.close()).toBe(firstOutcome)
|
|
|
+ expect(firstHost?.signal.aborted).toBe(true)
|
|
|
+ const beforeClosedInvalidation = fixture.invalidations
|
|
|
+ firstHost?.invalidate()
|
|
|
+ expect(fixture.invalidations).toBe(beforeClosedInvalidation)
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ expect(first.state).toBe('closed')
|
|
|
+ expect(second.state).toBe('active')
|
|
|
+ expect(fixture.shown[0]?.hidden).toBe(true)
|
|
|
+ expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
|
|
|
+ expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
|
|
|
+ expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
|
|
|
+ expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
|
|
|
+ expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
|
|
|
+ ;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
|
|
|
+ fixture.shown[1]!.component.handleInput?.('ignored')
|
|
|
+ await second.close()
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const numericMargin = manager.open({
|
|
|
+ create: () => component(['numeric margin']),
|
|
|
+ options: { margin: 1 },
|
|
|
+ })
|
|
|
+ expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
|
|
|
+ await numericMargin.close()
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const emptyOptions = manager.open({
|
|
|
+ create: () => component(['empty options']),
|
|
|
+ options: {},
|
|
|
+ })
|
|
|
+ expect(fixture.shown[3]?.options).toEqual({})
|
|
|
+ await emptyOptions.close()
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ expect(manager.hasActiveOverlay()).toBe(false)
|
|
|
+ await manager.dispose()
|
|
|
+ await manager.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const preAborted = new AbortController()
|
|
|
+ preAborted.abort()
|
|
|
+ const pre = manager.open({
|
|
|
+ signal: preAborted.signal,
|
|
|
+ create: () => component(['never']),
|
|
|
+ })
|
|
|
+ expect(await pre.closed).toEqual({ reason: 'aborted' })
|
|
|
+ expect(fixture.shown).toHaveLength(0)
|
|
|
+
|
|
|
+ const activeAbort = new AbortController()
|
|
|
+ let activeHost: TuiOverlayHost | undefined
|
|
|
+ const active = manager.open({
|
|
|
+ signal: activeAbort.signal,
|
|
|
+ create(host) {
|
|
|
+ activeHost = host
|
|
|
+ return component(['active'])
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const queuedAbort = new AbortController()
|
|
|
+ const queued = manager.open({
|
|
|
+ signal: queuedAbort.signal,
|
|
|
+ create: () => component(['queued']),
|
|
|
+ })
|
|
|
+ queuedAbort.abort()
|
|
|
+ expect(await queued.closed).toEqual({ reason: 'aborted' })
|
|
|
+ expect(queued.state).toBe('closed')
|
|
|
+ activeAbort.abort()
|
|
|
+ expect(await active.closed).toEqual({ reason: 'aborted' })
|
|
|
+ expect(activeHost?.signal.aborted).toBe(true)
|
|
|
+ await microtask()
|
|
|
+ expect(fixture.shown).toHaveLength(1)
|
|
|
+ expect(manager.hasActiveOverlay()).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not mount entries closed or aborted during component construction', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const closed = manager.open({
|
|
|
+ create(host) {
|
|
|
+ host.invalidate()
|
|
|
+ host.close()
|
|
|
+ return component(['closed during construction'])
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
|
|
+
|
|
|
+ const controller = new AbortController()
|
|
|
+ const aborted = manager.open({
|
|
|
+ signal: controller.signal,
|
|
|
+ create() {
|
|
|
+ controller.abort()
|
|
|
+ return component(['aborted during construction'])
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
|
|
|
+
|
|
|
+ const after = manager.open({ create: () => component(['after construction closes']) })
|
|
|
+ expect(fixture.shown).toHaveLength(1)
|
|
|
+ expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
|
|
|
+ await after.close()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('hides a handle returned after reentrant closure during mounting', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ fixture.onShow = (shown) => {
|
|
|
+ ;(shown as Component & { focused: boolean }).focused = true
|
|
|
+ }
|
|
|
+ const closed = manager.open({
|
|
|
+ create(host) {
|
|
|
+ return {
|
|
|
+ get focused(): boolean {
|
|
|
+ return false
|
|
|
+ },
|
|
|
+ set focused(_value: boolean) {
|
|
|
+ host.close()
|
|
|
+ },
|
|
|
+ render: () => ['closed during mount'],
|
|
|
+ invalidate() {},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
|
|
+ expect(fixture.shown[0]?.hidden).toBe(true)
|
|
|
+ expect(manager.hasActiveOverlay()).toBe(false)
|
|
|
+
|
|
|
+ delete fixture.onShow
|
|
|
+ const after = manager.open({ create: () => component(['after mount close']) })
|
|
|
+ expect(fixture.shown[1]?.hidden).toBe(false)
|
|
|
+ expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
|
|
|
+ await after.close()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stops admission and disposes active and queued overlays with the TUI', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const active = manager.open({ create: () => component(['active']) })
|
|
|
+ const queued = manager.open({ create: () => component(['queued']) })
|
|
|
+ manager.beginShutdown()
|
|
|
+ expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
|
|
|
+ await manager.dispose()
|
|
|
+ expect(await active.closed).toEqual({ reason: 'tui-disposed' })
|
|
|
+ expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
|
|
|
+ expect(fixture.shown).toHaveLength(1)
|
|
|
+ expect(fixture.shown[0]?.hidden).toBe(true)
|
|
|
+ await manager.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains factory, mount, render, input, and invalidation failures', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const factoryError = new Error('factory failed')
|
|
|
+ const factory = manager.open({
|
|
|
+ create() {
|
|
|
+ throw factoryError
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const afterFactory = manager.open({ create: () => component(['after factory']) })
|
|
|
+ expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
|
|
|
+ await microtask()
|
|
|
+ expect(afterFactory.state).toBe('active')
|
|
|
+ await afterFactory.close()
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const showError = new Error('show failed')
|
|
|
+ fixture.showError = showError
|
|
|
+ const show = manager.open({ create: () => component(['show']) })
|
|
|
+ expect(await show.closed).toEqual({ reason: 'error', error: showError })
|
|
|
+ delete fixture.showError
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const renderError = new Error('render failed')
|
|
|
+ const rendering = manager.open({
|
|
|
+ create: () => ({
|
|
|
+ render() {
|
|
|
+ throw renderError
|
|
|
+ },
|
|
|
+ invalidate() {
|
|
|
+ throw new Error('must be suppressed after the first failure')
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const renderComponent = fixture.shown.at(-1)!.component
|
|
|
+ expect(renderComponent.render(20)).toEqual([])
|
|
|
+ renderComponent.invalidate()
|
|
|
+ expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
|
|
|
+ expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const inputError = new Error('input failed')
|
|
|
+ const input = manager.open({
|
|
|
+ create: () => ({
|
|
|
+ render: () => ['input'],
|
|
|
+ handleInput() {
|
|
|
+ throw inputError
|
|
|
+ },
|
|
|
+ invalidate() {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ fixture.shown.at(-1)!.component.handleInput?.('x')
|
|
|
+ expect(await input.closed).toEqual({ reason: 'error', error: inputError })
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const invalidateError = new Error('invalidate failed')
|
|
|
+ let invalidatingHost: TuiOverlayHost | undefined
|
|
|
+ const invalidating = manager.open({
|
|
|
+ create(host) {
|
|
|
+ invalidatingHost = host
|
|
|
+ return {
|
|
|
+ render: () => ['invalidate'],
|
|
|
+ invalidate() {
|
|
|
+ throw invalidateError
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const invalidationsBeforeFailure = fixture.invalidations
|
|
|
+ invalidatingHost?.invalidate()
|
|
|
+ invalidatingHost?.invalidate()
|
|
|
+ expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
|
|
|
+ expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ const focusError = new Error('focus failed')
|
|
|
+ const focus = manager.open({
|
|
|
+ create: () => ({
|
|
|
+ get focused(): boolean {
|
|
|
+ throw focusError
|
|
|
+ },
|
|
|
+ set focused(_value: boolean) {
|
|
|
+ throw new Error('focus assignment failed')
|
|
|
+ },
|
|
|
+ get wantsKeyRelease(): boolean {
|
|
|
+ throw new Error('key-release query failed')
|
|
|
+ },
|
|
|
+ render: () => ['focus'],
|
|
|
+ invalidate() {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
|
|
|
+ expect(guarded.focused).toBe(false)
|
|
|
+ guarded.focused = true
|
|
|
+ expect(guarded.wantsKeyRelease).toBe(false)
|
|
|
+ expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
|
|
|
+ expect(fixture.errors).toEqual([
|
|
|
+ factoryError,
|
|
|
+ showError,
|
|
|
+ renderError,
|
|
|
+ inputError,
|
|
|
+ invalidateError,
|
|
|
+ focusError,
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains host redraw, overlay removal, and error-reporter failures', async () => {
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ let host: TuiOverlayHost | undefined
|
|
|
+ const invalidationError = new Error('redraw failed')
|
|
|
+ let redrawFails = false
|
|
|
+ fixture.driver.invalidate = () => {
|
|
|
+ if (redrawFails) throw invalidationError
|
|
|
+ }
|
|
|
+ fixture.driver.reportError = () => { throw new Error('report failed') }
|
|
|
+ const invalidating = manager.open({
|
|
|
+ create(value) {
|
|
|
+ host = value
|
|
|
+ return component()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ redrawFails = true
|
|
|
+ host?.invalidate()
|
|
|
+ expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
|
|
|
+ await microtask()
|
|
|
+
|
|
|
+ redrawFails = false
|
|
|
+ fixture.driver.invalidate = () => {}
|
|
|
+ const hideError = new Error('hide failed')
|
|
|
+ fixture.driver.show = () => ({
|
|
|
+ hide() { throw hideError },
|
|
|
+ setHidden() {},
|
|
|
+ isHidden: () => false,
|
|
|
+ focus() {},
|
|
|
+ unfocus() {},
|
|
|
+ isFocused: () => true,
|
|
|
+ })
|
|
|
+ const hiding = manager.open({
|
|
|
+ create(value) {
|
|
|
+ host = value
|
|
|
+ return component()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ host?.close()
|
|
|
+ expect(await hiding.closed).toEqual({ reason: 'closed' })
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('TuiExtensionService', () => {
|
|
|
+ it('binds an open overlay to the calling plugin fiber', async () => {
|
|
|
+ const ctx = new Context()
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const agent = {} as Agent
|
|
|
+ const provider = ctx.plugin((providerCtx) => {
|
|
|
+ new TuiExtensionServiceImpl(providerCtx, agent, manager)
|
|
|
+ })
|
|
|
+ await provider
|
|
|
+ let session: TuiOverlaySession | undefined
|
|
|
+ let host: TuiOverlayHost | undefined
|
|
|
+ const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
|
|
+ expect(consumerCtx.tui.agent).toBe(agent)
|
|
|
+ session = consumerCtx.tui.openOverlay({
|
|
|
+ create(value) {
|
|
|
+ host = value
|
|
|
+ return component(['plugin'])
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+ await consumer
|
|
|
+ expect(session?.state).toBe('active')
|
|
|
+
|
|
|
+ await consumer.dispose()
|
|
|
+ expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
|
|
|
+ expect(host?.signal.aborted).toBe(true)
|
|
|
+ await provider.dispose()
|
|
|
+ await manager.dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
|
|
|
+ const ctx = new Context()
|
|
|
+ const agent = {} as Agent
|
|
|
+ const sessions: TuiOverlaySession[] = []
|
|
|
+ let starts = 0
|
|
|
+ const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
|
|
+ starts += 1
|
|
|
+ sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
|
|
|
+ })
|
|
|
+
|
|
|
+ const firstFixture = driverFixture()
|
|
|
+ const firstManager = new TuiOverlayManager(firstFixture.driver)
|
|
|
+ const firstProvider = ctx.plugin((providerCtx) => {
|
|
|
+ new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
|
|
|
+ })
|
|
|
+ await firstProvider
|
|
|
+ await consumer
|
|
|
+ expect(starts).toBe(1)
|
|
|
+ await firstProvider.dispose()
|
|
|
+ expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
|
|
|
+
|
|
|
+ const secondFixture = driverFixture()
|
|
|
+ const secondManager = new TuiOverlayManager(secondFixture.driver)
|
|
|
+ const secondProvider = ctx.plugin((providerCtx) => {
|
|
|
+ new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
|
|
|
+ })
|
|
|
+ await secondProvider
|
|
|
+ await vi.waitFor(() => { expect(starts).toBe(2) })
|
|
|
+ await sessions[1]?.close()
|
|
|
+ await consumer.dispose()
|
|
|
+ await secondProvider.dispose()
|
|
|
+ await firstManager.dispose()
|
|
|
+ await secondManager.dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects new service work after terminal shutdown begins', async () => {
|
|
|
+ const ctx = new Context()
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const provider = ctx.plugin((providerCtx) => {
|
|
|
+ new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
|
|
+ })
|
|
|
+ await provider
|
|
|
+ manager.beginShutdown()
|
|
|
+ const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
|
|
+ expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
|
|
|
+ .toThrow('TUI is shutting down')
|
|
|
+ })
|
|
|
+ await consumer
|
|
|
+ await consumer.dispose()
|
|
|
+ await provider.dispose()
|
|
|
+ await manager.dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not admit an overlay when called from an unloading plugin', async () => {
|
|
|
+ const ctx = new Context()
|
|
|
+ const fixture = driverFixture()
|
|
|
+ const manager = new TuiOverlayManager(fixture.driver)
|
|
|
+ const provider = ctx.plugin((providerCtx) => {
|
|
|
+ new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
|
|
+ })
|
|
|
+ await provider
|
|
|
+ let error: unknown
|
|
|
+ const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
|
|
+ consumerCtx.effect(() => () => {
|
|
|
+ try {
|
|
|
+ consumerCtx.tui.openOverlay({ create: () => component() })
|
|
|
+ } catch (value) {
|
|
|
+ error = value
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ await consumer
|
|
|
+ await consumer.dispose()
|
|
|
+ expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
|
|
|
+ expect(fixture.shown).toHaveLength(0)
|
|
|
+ await provider.dispose()
|
|
|
+ await manager.dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+})
|