feat(host,client): ship the in-app directory browser as the browse package's client half
directory-picker-browse becomes dual-face: its browser half fills ui-workspace's two directory-flow holes with the Select Workspace Directory dialog (figma Harness 813-23126 family — Miller two-column view, breadcrumb with click-to-edit path zone, nested New-folder dialog), driving the node half's host.listDirectory/host.createDirectory and owning its locale namespace (directory-browser, zh default / en). The dialog moves here from ui-workspace wholesale — the trigger surfaces keep only the flow-hole owner conversation. apps/cli flips its one directory-picker row -native -> -browse, swapping the host backend and the client interaction together; picking now works for remote deployments out of the box. The keyless workspace-flow snapshot boots the browse bundle and drives menu -> dialog -> Documents -> project -> Open against the fixture tree.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, BrowseDirectoryFlow, inject } from '../src/client/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
const HOME = '/home/u'
|
||||
const homeListing: DirectoryListing = {
|
||||
path: HOME,
|
||||
home: HOME,
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }],
|
||||
entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }],
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
||||
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
||||
ctx.provide('workspaces', { listDirectory, createDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
const declare = () => slots.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, () => null)
|
||||
return { ctx, slots, listDirectory, createDirectory, declare }
|
||||
}
|
||||
|
||||
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
|
||||
return {
|
||||
open: true, busy: false,
|
||||
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('directory-picker-browse client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces', 'locale'])
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
const before = await bench()
|
||||
before.declare()
|
||||
const fiber = before.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
|
||||
// Registry-contribution disposal proof: the fiber going down empties the holes.
|
||||
await fiber.dispose()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
|
||||
after.declare()
|
||||
await Promise.resolve()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('registers the dialog dictionaries and binds this package namespace', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[0])[0]!
|
||||
const injected = (entry.inject as () => { t: (key: string) => string })()
|
||||
// zh is the shipped default locale.
|
||||
expect(injected.t('browser.title')).toBe('选择工作区目录')
|
||||
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
|
||||
})
|
||||
|
||||
it('drives the injected browse calls through the hole entry', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[1])[0]!
|
||||
const injected = (entry.inject as () => {
|
||||
listDirectory: (path?: string) => Promise<DirectoryListing>
|
||||
createDirectory: (path: string, name: string) => Promise<string>
|
||||
})()
|
||||
await expect(injected.listDirectory()).resolves.toBe(homeListing)
|
||||
await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`)
|
||||
expect(b.listDirectory).toHaveBeenCalledOnce()
|
||||
expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh')
|
||||
})
|
||||
|
||||
it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => {
|
||||
const props = owner()
|
||||
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
|
||||
const t = (key: string): string => key
|
||||
render(
|
||||
<BrowseDirectoryFlow
|
||||
{...props}
|
||||
listDirectory={listDirectory}
|
||||
createDirectory={vi.fn(async () => '')}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
|
||||
const openButton = await screen.findByRole('button', { name: 'browser.open' })
|
||||
openButton.click()
|
||||
expect(props.onPicked).toHaveBeenCalledWith(HOME)
|
||||
screen.getByRole('button', { name: 'browser.cancel' }).click()
|
||||
expect(props.onCancel).toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders nothing while the flow is closed', () => {
|
||||
const view = render(
|
||||
<BrowseDirectoryFlow
|
||||
{...owner({ open: false })}
|
||||
listDirectory={vi.fn(async () => homeListing)}
|
||||
createDirectory={vi.fn(async () => '')}
|
||||
t={key => key}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOME = '/home/u'
|
||||
const DOCS = `${HOME}/Documents`
|
||||
const HARNESS = `${DOCS}/harness`
|
||||
|
||||
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
||||
function listingFor(path?: string): DirectoryListing {
|
||||
const target = path ?? HOME
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[HOME]: {
|
||||
path: HOME,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
],
|
||||
entries: [
|
||||
{ name: '.config', path: `${HOME}/.config`, hidden: true },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
],
|
||||
},
|
||||
[DOCS]: {
|
||||
path: DOCS,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
],
|
||||
entries: [{ name: 'harness', path: HARNESS, hidden: false }],
|
||||
},
|
||||
[HARNESS]: {
|
||||
path: HARNESS,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
{ name: 'harness', path: HARNESS, hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
},
|
||||
}
|
||||
const found = tree[target]
|
||||
if (found === undefined) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {}) {
|
||||
const listDirectory = vi.fn(async (path?: string) => listingFor(path))
|
||||
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
||||
const onOpen = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const props = {
|
||||
open: true,
|
||||
listDirectory,
|
||||
createDirectory,
|
||||
onOpen,
|
||||
onClose,
|
||||
busy: false,
|
||||
t: (key: string, params?: Record<string, unknown>) => (params === undefined ? key : `${key}:${String(params.name)}`),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<DirectoryBrowser {...props} />)
|
||||
return { view, props, listDirectory, createDirectory, onOpen, onClose }
|
||||
}
|
||||
|
||||
/** The rendered level columns, left-to-right. */
|
||||
function columns(): HTMLElement[] {
|
||||
return screen.getAllByRole('list')
|
||||
}
|
||||
|
||||
describe('DirectoryBrowser', () => {
|
||||
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(undefined)
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
|
||||
})
|
||||
|
||||
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
const [level, preview] = columns()
|
||||
const selectedRow = within(level!).getByRole('listitem')
|
||||
expect(selectedRow.textContent).toBe('Documents')
|
||||
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||
expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
|
||||
expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS)
|
||||
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('advances one level when a right-column row is picked', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(within(columns()[1]!).getByRole('listitem'))
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
|
||||
const [level] = columns()
|
||||
const selectedRow = within(level!).getByRole('listitem')
|
||||
expect(selectedRow.textContent).toBe('harness')
|
||||
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('jumps back through a crumb into a fresh single-column level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
expect(b.onOpen).toHaveBeenCalledWith(HOME)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
expect(b.onOpen).toHaveBeenLastCalledWith(DOCS)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
|
||||
const busy = mount({ busy: true })
|
||||
await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
expect(input.value).toBe(HOME)
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(again, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(again, { key: 'Enter' })
|
||||
expect(b.listDirectory).toHaveBeenCalledTimes(2)
|
||||
fireEvent.keyDown(again, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: '/nope' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') })
|
||||
expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
|
||||
const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
|
||||
await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
|
||||
expect(raw.onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the full ancestry when the level sits outside the home subtree', async () => {
|
||||
const outside: DirectoryListing = {
|
||||
path: '/srv/data',
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
{ name: 'data', path: '/srv/data', hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => outside) })
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() })
|
||||
expect(screen.getByRole('button', { name: '/' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a folder through the nested dialog and lands with it selected', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
// The nested dialog names the create target (the selected folder).
|
||||
expect(screen.getByText('browser.createIn:Documents')).toBeTruthy()
|
||||
// The created folder becomes listable (like the real backend after mkdir).
|
||||
b.listDirectory.mockImplementation(async (path?: string) => {
|
||||
if (path === `${DOCS}/fresh`) {
|
||||
return {
|
||||
path: `${DOCS}/fresh`, home: HOME,
|
||||
crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }],
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
if (path === DOCS) {
|
||||
const docs = listingFor(DOCS)
|
||||
return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] }
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') })
|
||||
// The create target became the level and the new folder its selection.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||
const level = columns()[0]!
|
||||
const rows = within(level).getAllByRole('listitem')
|
||||
expect(rows.some(row => row.textContent === 'fresh' && row.getAttribute('aria-current') === 'true')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.createDirectory.mockRejectedValueOnce(
|
||||
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy()
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
// A blank name never submits.
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(b.createDirectory).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: 'x' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
|
||||
// The nested Cancel button and the nested mask both close only the child dialog.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const nested = screen.getByRole('dialog', { name: 'browser.newFolder' })
|
||||
fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' }))
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||
fireEvent.click(masks[masks.length - 1]!)
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a selection-preview failure while keeping the selection marked', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.listDirectory.mockRejectedValueOnce(
|
||||
new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }))
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true')
|
||||
// No preview column arrived for the failed selection.
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a post-create relist failure on the browser surface', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
// Creation succeeds, but relisting the target fails afterwards.
|
||||
b.listDirectory.mockRejectedValueOnce(new Error('level vanished'))
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') })
|
||||
})
|
||||
|
||||
it('drops a stale child listing that resolves after a crumb jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let resolveSlow!: (value: DirectoryListing) => void
|
||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
resolveSlow(listingFor(DOCS))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The superseded selection preview did not reopen the second pane.
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops a stale failure that rejects after a newer navigation', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let rejectSlow!: (reason: unknown) => void
|
||||
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
rejectSlow(new Error('too late to matter'))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('drops a stale navigation failure that rejects after a newer jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
let rejectSlow!: (reason: unknown) => void
|
||||
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
// A slow crumb jump superseded by a second jump.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) })
|
||||
rejectSlow(new Error('late nav failure'))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stale navigation listing that resolves after a newer jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
let resolveSlow!: (value: DirectoryListing) => void
|
||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
resolveSlow(listingFor(undefined))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The stale home listing did not replace the newer Documents level.
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
})
|
||||
|
||||
it('names the create target by its path when the level reports no crumbs', async () => {
|
||||
const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [] }
|
||||
mount({ listDirectory: vi.fn(async () => bare) })
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(false)
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses to close the nested dialog while the creation is in flight', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let settleCreate!: (path: string) => void
|
||||
b.createDirectory.mockReturnValueOnce(new Promise<string>((settle) => { settleCreate = settle }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'slow' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
// Escape and the mask are both inert while creating.
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||
fireEvent.click(masks[masks.length - 1]!)
|
||||
expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
|
||||
settleCreate(`${HOME}/slow`)
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
})
|
||||
|
||||
it('starts back at home on reopen', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open />)
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user