소스 검색

test: cover file subscription and tab lifecycle edge cases

imccyu 6 일 전
부모
커밋
755e3ceca7

+ 57 - 0
packages/api/workspace-files/tests/change-feed.client.spec.ts

@@ -6,6 +6,7 @@
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { describe, expect, it } from 'vitest'
 import { ChangeFeed } from '../src/client/change-feed.ts'
+import type { WorkspaceFileWatchFrame } from '../src/types.ts'
 import { FakeRemote, peek, settle } from './fake-remote.client.ts'
 
 const S1 = 's1' as SessionId
@@ -23,6 +24,33 @@ function harness() {
 }
 
 describe('ChangeFeed — one Host stream per session', () => {
+  it('starts a later follower from the existing session acknowledgement without opening another stream', async () => {
+    const { remote, feed } = harness()
+    const controller = new AbortController()
+    const first = feed.follow(S1, 'first-resource', controller.signal)
+    try {
+      await expect(first.ready).resolves.toBe(true)
+      const second = feed.follow(S1, 'second-resource', controller.signal)
+      await expect(second.ready).resolves.toBe(true)
+      expect(remote.calls).toEqual(['changes', 'accept'])
+      expect(remote.opened).toHaveLength(1)
+
+      second.bind('/w/second.txt')
+      const iterator = second[Symbol.asyncIterator]()
+      await remote.opened[0]!.source.deliver({
+        kind: 'change', change: { absolutePath: '/w/second.txt', version: 'v1' },
+      })
+      await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
+      controller.abort()
+      await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
+      await feed.settle()
+      expect(remote.disposed).toEqual(['workspace file changes of s1'])
+    } finally {
+      controller.abort()
+      await feed.settle()
+    }
+  })
+
   it('shares one stream among the followers of a session and opens another per session', async () => {
     const { remote, follow } = harness()
     follow(S1, '/w/a.txt')
@@ -176,6 +204,35 @@ describe('ChangeFeed — delivery', () => {
 })
 
 describe('ChangeFeed — a follower ends', () => {
+  it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => {
+    const { remote, feed } = harness()
+    const controller = new AbortController()
+    const first = feed.follow(S1, 'first-resource', controller.signal)
+    const second = feed.follow(S1, 'second-resource', controller.signal)
+    const firstIterator = first[Symbol.asyncIterator]()
+    const secondIterator = second[Symbol.asyncIterator]()
+    try {
+      await expect(Promise.all([first.ready, second.ready])).resolves.toEqual([true, true])
+      const endings = Promise.all([firstIterator.next(), secondIterator.next()])
+      const source = remote.opened[0]!.source
+      // The Remote double supplies decoded wire data, including an unknown protocol tag.
+      const wireFrame: unknown = JSON.parse('{"kind":"future-frame"}')
+      source.push(wireFrame as WorkspaceFileWatchFrame)
+      await expect(endings).resolves.toEqual([
+        { done: true, value: undefined },
+        { done: true, value: undefined },
+      ])
+      await feed.settle()
+      expect(source.aborted).toBe(true)
+      expect(remote.disposed).toEqual(['workspace file changes of s1'])
+      expect(remote.opened).toHaveLength(1)
+    } finally {
+      controller.abort()
+      await Promise.all([firstIterator.return?.(), secondIterator.return?.()])
+      await feed.settle()
+    }
+  })
+
   it('ends on its signal and drops nothing queued before it', async () => {
     const { remote, follow } = harness()
     const mine = follow(S1, '/w/a.txt')

+ 8 - 0
packages/api/workspace-files/tests/changes.spec.ts

@@ -184,6 +184,14 @@ describe('workspaceFiles.changes — ending', () => {
     expect(await pending).toEqual({ done: true, value: undefined })
   })
 
+  it('drops queued observations when cancelled after ready but before the next pull', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    await observe(join(harness.workspace, 'queued-before-abort.txt'), present('v1'))
+    stream.controller.abort()
+    await expect(stream.next()).resolves.toEqual({ done: true, value: undefined })
+  })
+
   it('ends when its signal aborts during setup, without delivering anything', async () => {
     const stream = open(harness.endpoint())
     const pending = stream.next()

+ 31 - 0
packages/client/ui-sidebar-right/tests/stores.client.spec.ts

@@ -33,6 +33,37 @@ function harness() {
 }
 
 describe('createSidebarRightStore — the sequence', () => {
+  it.each(['left', 'right'] as const)('splits one pane at its %s edge and records the tab move as one reversible intent', (zone) => {
+    const { actions, layout, entries, guide } = harness()
+    actions.openContent(SESSION, {
+      kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a',
+    }, () => {})
+    const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
+    if (text === undefined) throw new Error('expected the text tab')
+    const before = layout()
+    const home = getPane(before, before.rootId).id
+    const recorded = entries()
+
+    actions.dropTab(SESSION, text.id, home, zone)
+
+    const split = getSplit(layout(), layout().rootId)
+    const destination = findTabPane(layout(), text.id)
+    expect(split.axis).toBe('row')
+    expect(split.children).toEqual(zone === 'left' ? [destination.id, home] : [home, destination.id])
+    expect(split.sizes).toEqual([0.5, 0.5])
+    expect(destination.tabs).toEqual([text.id])
+    expect(destination.activeTabId).toBe(text.id)
+    expect(getPane(layout(), home).tabs).toEqual([guide()])
+    expect(layout().tabs).toEqual(before.tabs)
+    expect(entries()).toBe(recorded + 1)
+    const dropped = layout()
+
+    actions.undo(SESSION)
+    expect(layout()).toEqual(before)
+    actions.redo(SESSION)
+    expect(layout()).toEqual(dropped)
+  })
+
   it('allows two docked panes and rejects further splits without recording', () => {
     const { actions, layout, entries } = harness()
     actions.splitPane(SESSION)

+ 121 - 0
packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx

@@ -0,0 +1,121 @@
+// @vitest-environment jsdom
+/** Tab information refuses readers whose committed record and navigation binding disagree. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, renderHook } from '@testing-library/react'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { keyedObservableHook } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bindings.tsx'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { UseSidebarRightTabInfo } from '../src/client/contract/slots.ts'
+import { createSidebarRightStore } from '../src/client/stores.ts'
+import { TabDomain, type TabOccurrence } from '../src/client/tab-domain.ts'
+import { tabInfoFactory, type TabHookContext } from '../src/client/tab-info.ts'
+
+const SESSION = 's-info' as SessionId
+const ADDRESS = 'dsh-resource://file/session/s-info/a.txt'
+const domains: TabDomain[] = []
+
+afterEach(() => {
+  cleanup()
+  for (const domain of domains.splice(0)) domain.dispose()
+})
+
+function harness() {
+  const instance = createSidebarRightStore(() => 'Start').create()
+  const domain = new TabDomain({ openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() }, vi.fn())
+  domains.push(domain)
+  const navigationSources = new Map<string, TabOccurrence['navigation']>()
+  const useStore = bindSnapshotSelector(instance)
+  // The renderer erases the keyed snapshot type; this family contains only tab navigation sources.
+  const useTabNavigation = keyedObservableHook(key => navigationSources.get(key)) as TabHookContext['useTabNavigation']
+  // Only sessionId is read from the standard share by this internal factory.
+  const standard = { sessionId: SESSION } as Parameters<typeof tabInfoFactory>[0]
+  const layout = () => instance.getSnapshot().bySession[SESSION]?.layout
+  const sync = (): void => {
+    const committed = layout()
+    if (committed === undefined) throw new Error('expected the layout to commit')
+    domain.sync(SESSION, committed)
+  }
+  const bind = (tabId: TabId): UseSidebarRightTabInfo => {
+    const occurrence = domain.occurrence(SESSION, { id: tabId })
+    navigationSources.set(tabId, occurrence.navigation)
+    return tabInfoFactory(standard, {
+      tabId, title: false, fullscreen: false, signal: occurrence.signal, actions: occurrence.tabActions, useStore, useTabNavigation,
+    })
+  }
+  const open = (beforeCommit?: (tabId: TabId) => void): TabId => {
+    let opened: TabId | undefined
+    instance.actions.openContent(SESSION, { kind: 'text', contentId: ADDRESS, title: 'a' }, (tabId) => {
+      opened = tabId
+      domain.navigate(SESSION, tabId, { address: ADDRESS, params: undefined })
+      beforeCommit?.(tabId)
+    })
+    if (opened === undefined) throw new Error('expected the tab to open')
+    sync()
+    return opened
+  }
+  return { instance, domain, navigationSources, layout, sync, bind, open }
+}
+
+function expectUncommitted(useTabInfo: UseSidebarRightTabInfo, tabId: TabId): void {
+  const message = `sidebarRight: tab "${tabId}" is not committed in session "${SESSION}"`
+  const suppressExpected = (event: ErrorEvent): void => {
+    if (event.error instanceof Error && event.error.message === message) event.preventDefault()
+  }
+  const report = vi.spyOn(console, 'error').mockImplementation(() => {})
+  window.addEventListener('error', suppressExpected)
+  try {
+    expect(() => renderHook(useTabInfo)).toThrow(message)
+  } finally {
+    window.removeEventListener('error', suppressExpected)
+    report.mockRestore()
+  }
+}
+
+describe('tabInfoFactory committed-record relation', () => {
+  it('rejects a navigation before the first layout commit and reads the same record after commit', () => {
+    const h = harness()
+    const tabId = h.open((opened) => {
+      expect(h.layout()).toBeUndefined()
+      expectUncommitted(h.bind(opened), opened)
+    })
+    const view = renderHook(h.bind(tabId))
+    expect(view.result.current.tab).toMatchObject({ id: tabId, contentId: ADDRESS })
+    expect(view.result.current.tab.signal.aborted).toBe(false)
+    expect(view.result.current.tab.navigation.revision).toBe(1)
+  })
+
+  it('rejects a retained reader after its record closes, even while its navigation snapshot is held', () => {
+    const h = harness()
+    const tabId = h.open()
+    const useTabInfo = h.bind(tabId)
+    const view = renderHook(useTabInfo)
+    const { signal, navigation } = view.result.current.tab
+    view.unmount()
+
+    h.instance.actions.closeTab(SESSION, tabId)
+    h.sync()
+
+    expect(signal.aborted).toBe(true)
+    expect(h.layout()?.tabs[tabId]).toBeUndefined()
+    expect(h.navigationSources.get(tabId)?.getSnapshot()).toBe(navigation)
+    expectUncommitted(useTabInfo, tabId)
+  })
+
+  it('rejects a retained record after its keyed navigation binding is released', () => {
+    const h = harness()
+    const tabId = h.open()
+    const useTabInfo = h.bind(tabId)
+    const view = renderHook(useTabInfo)
+    expect(view.result.current.tab.id).toBe(tabId)
+    view.unmount()
+    const committed = h.layout()
+
+    h.domain.dispose()
+    h.navigationSources.clear()
+
+    expect(h.layout()).toBe(committed)
+    expect(h.layout()?.tabs[tabId]).toBeDefined()
+    expectUncommitted(useTabInfo, tabId)
+  })
+})