Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts: # src/client/MarkdownHtml.tsx
This commit is contained in:
@@ -281,6 +281,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await newTabButton.click()
|
||||
const item = page.getByRole('menuitem', { name: title }).first()
|
||||
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
|
||||
if (title === BUILTIN_TABS[0]) {
|
||||
// Layout-level evidence for the compact density: rows render ~26px
|
||||
// tall (the default density would be 40px). Guard loose at ≤30px so
|
||||
// font/zoom differences across runners cannot flake it.
|
||||
const rowHeight = await item.evaluate(el => el.getBoundingClientRect().height)
|
||||
expect(rowHeight, '+ menu rows must render at the compact density (≤30px)').toBeLessThanOrEqual(30)
|
||||
}
|
||||
await item.click()
|
||||
// Let the activation commit (including any lazy-chunk fetch) before the
|
||||
// crash assertions run.
|
||||
|
||||
@@ -204,3 +204,45 @@ describe('FileTree open-with menu', () => {
|
||||
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
|
||||
const ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
document.body.removeAttribute(ATTR)
|
||||
})
|
||||
|
||||
it('publishes "down" for a top-of-tree opening and clears on selection', async () => {
|
||||
harness = await mountTree()
|
||||
// openMenu() right-clicks at (20, 30) — the upper half, plenty of right
|
||||
// room in jsdom's default viewport.
|
||||
openMenu(harness.container)
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Zed')
|
||||
act(() => { zedRow!.click() })
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
})
|
||||
|
||||
it('adds "left" when the cursor sits within the right-hand submenu room', async () => {
|
||||
harness = await mountTree()
|
||||
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: window.innerWidth - 10, clientY: 30 })
|
||||
act(() => { fileRow(harness.container).dispatchEvent(nearRight) })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down left')
|
||||
})
|
||||
|
||||
it('clears the attribute on unmount with the menu still open', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
harness.unmount()
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* FileTree explorer mutations: the context menu's rename (inline row editor)
|
||||
* and delete (confirmation modal) — menu-item visibility (never on the
|
||||
* workspace root row), the commit/cancel flows, the API calls with the row
|
||||
* path, the tab-reconciliation callbacks, and the failure strip.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
import { setupReactAct } from './test-utils.ts'
|
||||
setupReactAct()
|
||||
|
||||
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
// vi.mock factories are hoisted above every declaration, so the shared
|
||||
// mocks ride vi.hoisted (mockClear in afterEach keeps the implementations).
|
||||
const { fsRename, fsRemove } = vi.hoisted(() => ({
|
||||
fsRename: vi.fn(async () => ({ path: '/tmp/renamed.ts' })),
|
||||
fsRemove: vi.fn(async () => ({ path: '/tmp/a.ts' })),
|
||||
}))
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
{ name: 'sub', path: '/tmp/sub', isDir: true },
|
||||
],
|
||||
}),
|
||||
fsRename,
|
||||
fsRemove,
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
isOutsideWorkspaceMessage: () => false,
|
||||
}))
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
onPathRenamed: ReturnType<typeof vi.fn>
|
||||
onPathDeleted: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
async function mountTree(): Promise<Harness> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const onPathRenamed = vi.fn()
|
||||
const onPathDeleted = vi.fn()
|
||||
await act(async () => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
store: createSidebarStore(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onOpenFileNewTab: () => {},
|
||||
onOpenFileSide: () => {},
|
||||
onReferenceFile: () => {},
|
||||
onPathRenamed,
|
||||
onPathDeleted,
|
||||
refreshTick: 0,
|
||||
onUploadRequest: () => {},
|
||||
busy: false,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
onPathRenamed,
|
||||
onPathDeleted,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
/** One tree row by its displayed name (the root row included; it carries no
|
||||
* role="button", so the query rides the row class). */
|
||||
function rowByName(container: HTMLDivElement, name: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
|
||||
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
|
||||
if (row === undefined) throw new Error(`row "${name}" not found`)
|
||||
return row
|
||||
}
|
||||
|
||||
function openMenu(container: HTMLDivElement, name: string): void {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
|
||||
act(() => { rowByName(container, name).dispatchEvent(event) })
|
||||
}
|
||||
|
||||
function menuLabels(): string[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(item => item.textContent ?? '')
|
||||
}
|
||||
|
||||
function clickMenuitem(label: string): void {
|
||||
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(el => el.textContent === label)
|
||||
if (item === undefined) throw new Error(`menuitem "${label}" not found`)
|
||||
act(() => { item.click() })
|
||||
}
|
||||
|
||||
/** Set a controlled input's value the React way (native setter + input). */
|
||||
function setNativeValue(el: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
if (setter === undefined) throw new Error('no native value setter')
|
||||
setter.call(el, value)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
function pressKey(el: HTMLElement, key: string): void {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
fsRename.mockClear()
|
||||
fsRemove.mockClear()
|
||||
})
|
||||
|
||||
describe('FileTree rename/delete menu items', () => {
|
||||
it('offers rename and delete on file and directory rows, never on the root', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
expect(menuLabels()).toContain('Rename')
|
||||
expect(menuLabels()).toContain('Delete')
|
||||
openMenu(harness.container, 'sub')
|
||||
expect(menuLabels()).toContain('Rename')
|
||||
// The workspace root row (cwd itself) is the session — no mutations.
|
||||
openMenu(harness.container, 'tmp')
|
||||
expect(menuLabels()).not.toContain('Rename')
|
||||
expect(menuLabels()).not.toContain('Delete')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileTree inline rename', () => {
|
||||
it('replaces the row with a prefilled editor; Enter commits via fsRename + onPathRenamed', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Rename')
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')
|
||||
expect(input).not.toBeNull()
|
||||
expect(input!.value).toBe('a.ts')
|
||||
setNativeValue(input!, 'b.ts')
|
||||
await act(async () => { pressKey(input!, 'Enter') })
|
||||
expect(fsRename).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts', 'b.ts')
|
||||
expect(harness.onPathRenamed).toHaveBeenCalledWith('/tmp/a.ts', '/tmp/renamed.ts')
|
||||
// The editor row is gone (the commit closed it).
|
||||
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('Escape cancels without touching the API', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Rename')
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
|
||||
setNativeValue(input, 'b.ts')
|
||||
await act(async () => { pressKey(input, 'Escape') })
|
||||
expect(fsRename).not.toHaveBeenCalled()
|
||||
expect(harness.onPathRenamed).not.toHaveBeenCalled()
|
||||
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects separator names client-side with the error strip, no API call', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Rename')
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')!
|
||||
setNativeValue(input, 'a/b.ts')
|
||||
await act(async () => { pressKey(input, 'Enter') })
|
||||
expect(fsRename).not.toHaveBeenCalled()
|
||||
const alert = harness.container.querySelector('[role="alert"]')
|
||||
expect(alert?.textContent).toContain('path separators')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileTree delete confirmation', () => {
|
||||
it('confirms through the modal, then calls fsRemove + onPathDeleted', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Delete')
|
||||
const dialog = document.querySelector('[role="dialog"]')
|
||||
expect(dialog?.textContent).toContain('Delete "a.ts"?')
|
||||
expect(dialog?.textContent).toContain('permanently deletes the file')
|
||||
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
|
||||
await act(async () => { confirm.click() })
|
||||
expect(fsRemove).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts')
|
||||
expect(harness.onPathDeleted).toHaveBeenCalledWith('/tmp/a.ts', false)
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('the directory copy names the recursive wipe', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'sub')
|
||||
clickMenuitem('Delete')
|
||||
const dialog = document.querySelector('[role="dialog"]')
|
||||
expect(dialog?.textContent).toContain('everything inside it')
|
||||
})
|
||||
|
||||
it('cancel closes the modal without calling anything', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Delete')
|
||||
const cancel = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Cancel')!
|
||||
act(() => { cancel.click() })
|
||||
expect(fsRemove).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a server failure lands in the dismissable strip', async () => {
|
||||
fsRemove.mockRejectedValueOnce(new Error('boom: refused'))
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container, 'a.ts')
|
||||
clickMenuitem('Delete')
|
||||
const confirm = [...document.querySelectorAll<HTMLElement>('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
|
||||
await act(async () => { confirm.click() })
|
||||
const alert = harness.container.querySelector('[role="alert"]')
|
||||
expect(alert?.textContent).toContain('boom: refused')
|
||||
expect(harness.onPathDeleted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { writeWorkspaceUpload } from '../src/fs-operations.ts'
|
||||
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts'
|
||||
|
||||
/** The test workspace root (each suite gets its own temp tree). */
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
|
||||
@@ -150,3 +150,92 @@ describe('writeWorkspaceUpload', () => {
|
||||
expect(readFileSync(target, 'utf8')).toBe('original')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameWorkspaceEntry', () => {
|
||||
it('renames a file within its directory', async () => {
|
||||
writeFileSync(join(root, 'old-name.txt'), 'x')
|
||||
const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' })
|
||||
// The returned path is CANONICAL (realpath-resolved ancestors — on macOS
|
||||
// the tmpdir's /var becomes /private/var), so assert the shape, not the
|
||||
// lexical spelling.
|
||||
expect(path.endsWith('new-name.txt')).toBe(true)
|
||||
expect(existsSync(join(root, 'old-name.txt'))).toBe(false)
|
||||
expect(existsSync(join(root, 'new-name.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renames a directory (recursive content moves with it)', async () => {
|
||||
mkdirSync(join(root, 'olddir/nested'), { recursive: true })
|
||||
writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x')
|
||||
await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' })
|
||||
expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x')
|
||||
})
|
||||
|
||||
it('is a no-op for the same name (destination-exists refusal must not bite)', async () => {
|
||||
writeFileSync(join(root, 'same.txt'), 'x')
|
||||
await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' })
|
||||
expect(existsSync(join(root, 'same.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses single-segment violations (empty, dot, traversal, separators)', async () => {
|
||||
writeFileSync(join(root, 'r.txt'), 'x')
|
||||
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name }))
|
||||
.rejects.toMatchObject({ code: 'bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses an existing destination instead of clobbering it', async () => {
|
||||
writeFileSync(join(root, 'src.txt'), 'src')
|
||||
writeFileSync(join(root, 'dst.txt'), 'dst')
|
||||
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' }))
|
||||
.rejects.toMatchObject({ code: 'fs-error', status: 409 })
|
||||
expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst')
|
||||
})
|
||||
|
||||
it('refuses the workspace root and missing sources', async () => {
|
||||
await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
})
|
||||
|
||||
it('renames a symlink ROW, not its target', async () => {
|
||||
writeFileSync(join(root, 'target.txt'), 't')
|
||||
symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt'))
|
||||
await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' })
|
||||
expect(existsSync(join(root, 'alias.txt'))).toBe(false)
|
||||
expect(existsSync(join(root, 'alias2.txt'))).toBe(true)
|
||||
expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeWorkspaceEntry', () => {
|
||||
it('unlinks a file', async () => {
|
||||
writeFileSync(join(root, 'gone.txt'), 'x')
|
||||
await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') })
|
||||
expect(existsSync(join(root, 'gone.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a directory recursively', async () => {
|
||||
mkdirSync(join(root, 'tree/sub'), { recursive: true })
|
||||
writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x')
|
||||
await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') })
|
||||
expect(existsSync(join(root, 'tree'))).toBe(false)
|
||||
})
|
||||
|
||||
it('unlinks a symlink row without touching (or recursing into) its target', async () => {
|
||||
mkdirSync(join(root, 'realdir'), { recursive: true })
|
||||
writeFileSync(join(root, 'realdir/keep.txt'), 'x')
|
||||
symlinkSync(join(root, 'realdir'), join(root, 'linkdir'))
|
||||
await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') })
|
||||
expect(existsSync(join(root, 'linkdir'))).toBe(false)
|
||||
expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x')
|
||||
})
|
||||
|
||||
it('refuses the workspace root and missing paths', async () => {
|
||||
await expect(removeWorkspaceEntry({ cwd: root, path: root }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') }))
|
||||
.rejects.toMatchObject({ code: 'fs-error' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocument (local markdown images)', () => {
|
||||
it('renders markdown-syntax local images through the /sidebar/file route', async () => {
|
||||
const { container, root } = await renderDocument([
|
||||
'# Title',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'![logo][logo]',
|
||||
'',
|
||||
'[logo]: ./logo.png',
|
||||
].join('\n'))
|
||||
const imgs = [...container.querySelectorAll('img')]
|
||||
expect(imgs.length, 'both local images must render as <img>, not alt fallback').toBe(2)
|
||||
expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
|
||||
expect(imgs[0]?.getAttribute('alt')).toBe('icon')
|
||||
expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
|
||||
expect(imgs[1]?.getAttribute('alt')).toBe('logo')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
|
||||
const { container, root } = await renderDocument([
|
||||
'<div align="center">badges</div>',
|
||||
'',
|
||||
'',
|
||||
].join('\n'))
|
||||
const img = container.querySelector('img')
|
||||
expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
|
||||
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('keeps remote image destinations untouched', async () => {
|
||||
const { container, root } = await renderDocument('')
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('is idempotent for already-rewritten media URLs', async () => {
|
||||
const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
|
||||
const { container, root } = await renderDocument(``)
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe(url)
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The context-menu submenu flip geometry: the pure token computation behind
|
||||
* the body attribute layout.css consumes (see src/client/menu-flip.ts for
|
||||
* the contract) plus the attribute lifecycle of the hook itself.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { createElement, useState } from 'react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderRoot, setupReactAct } from './test-utils.ts'
|
||||
import { SUBMENU_FLIP_ATTR, submenuFlipTokens, useSubmenuFlip } from '../src/client/menu-flip.ts'
|
||||
|
||||
setupReactAct()
|
||||
|
||||
describe('layout.css contract', () => {
|
||||
it('consumes the exact attribute name menu-flip.ts publishes', () => {
|
||||
// The two halves of the flip mechanism can drift silently: renaming the
|
||||
// attribute here would leave layout.css matching nothing (submenus revert
|
||||
// to overflowing) with every test still green. Tie them together. Specs
|
||||
// run from the repo root (chunk-artifact.spec.ts reads lib/ the same way).
|
||||
const css = readFileSync('src/client/layout.css', 'utf8')
|
||||
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="down"] div[role="menu"] div[role="menu"]`)
|
||||
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
|
||||
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
|
||||
})
|
||||
|
||||
it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => {
|
||||
// Part 1 keeps the parent row lit while its submenu is open (the Menu
|
||||
// marks it aria-expanded); part 2 tightens the card gap to 4px with the
|
||||
// hover corridor narrowed to match. Losing either sends the submenu back
|
||||
// to reading as an unrelated floating card.
|
||||
const css = readFileSync('src/client/layout.css', 'utf8')
|
||||
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`)
|
||||
expect(css).toContain('left: calc(100% + 4px)')
|
||||
expect(css).toContain('right: calc(100% + 4px)')
|
||||
expect(css).toContain('width: 4px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submenuFlipTokens', () => {
|
||||
// Default geometry (1024x768): the panel is docked right, so the
|
||||
// interesting split is the right-hand 400px strip and the vertical half.
|
||||
it('flips down for a cursor in the upper half, keeps primitive defaults below', () => {
|
||||
expect(submenuFlipTokens(500, 20, 1024, 768)).toBe('down')
|
||||
expect(submenuFlipTokens(500, 700, 1024, 768)).toBe('')
|
||||
})
|
||||
|
||||
it('flips left when the right-hand room cannot fit card + submenu', () => {
|
||||
// 700 + 400 > 1024 → left; the cursor is low, so no down token.
|
||||
expect(submenuFlipTokens(700, 700, 1024, 768)).toBe('left')
|
||||
// Both tokens compose for a top-right opening.
|
||||
expect(submenuFlipTokens(700, 20, 1024, 768)).toBe('down left')
|
||||
})
|
||||
|
||||
it('uses the exact boundary inclusively (midpoint and width-minus-room still flip)', () => {
|
||||
expect(submenuFlipTokens(500, 384, 1024, 768)).toBe('')
|
||||
expect(submenuFlipTokens(500, 383, 1024, 768)).toBe('down')
|
||||
expect(submenuFlipTokens(624, 700, 1024, 768)).toBe('')
|
||||
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
|
||||
})
|
||||
|
||||
it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
|
||||
// A 600px window: any cursor past x=200 flips; the left strip cannot
|
||||
// fit a right-growing submenu beside the 218px card either way.
|
||||
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
|
||||
expect(submenuFlipTokens(500, 700, 600, 800)).toBe('left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSubmenuFlip', () => {
|
||||
const ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
/** Mount a probe whose menu state the spec drives via the returned setter. */
|
||||
function mountFlipProbe(initial: { x: number; y: number } | null): { setMenu: (next: { x: number; y: number } | null) => void, unmount: () => void } {
|
||||
let applyMenu: (next: { x: number; y: number } | null) => void = () => {}
|
||||
function Probe(): React.ReactElement {
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(initial)
|
||||
applyMenu = setMenu
|
||||
useSubmenuFlip(menu)
|
||||
return <div />
|
||||
}
|
||||
const rendered = renderRoot(createElement(Probe))
|
||||
return {
|
||||
setMenu: (next) => { act(() => { applyMenu(next) }) },
|
||||
unmount: rendered.unmount,
|
||||
}
|
||||
}
|
||||
|
||||
it('publishes tokens while the menu is open and clears on close', () => {
|
||||
const probe = mountFlipProbe({ x: 700, y: 20 })
|
||||
try {
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down left')
|
||||
probe.setMenu(null)
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
} finally {
|
||||
probe.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('tracks the position across consecutive openings', () => {
|
||||
const probe = mountFlipProbe({ x: 100, y: 20 })
|
||||
try {
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
// Still open, now lower-left: the attribute stays (menu lifetime) but
|
||||
// carries no token, so the primitive's own geometry applies.
|
||||
probe.setMenu({ x: 100, y: 700 })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('')
|
||||
probe.setMenu({ x: 700, y: 700 })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('left')
|
||||
} finally {
|
||||
probe.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the attribute when the owner unmounts with the menu still open', () => {
|
||||
const probe = mountFlipProbe({ x: 100, y: 20 })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
probe.unmount()
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -113,6 +113,7 @@ describe('sidechat.start', () => {
|
||||
sessionId: string
|
||||
meta: Record<string, unknown>
|
||||
seed: readonly { type: string; data: Record<string, unknown> }[]
|
||||
inheritedEventCount: number
|
||||
agentOptions: { provider: string; model: string }
|
||||
setup: unknown
|
||||
}
|
||||
@@ -120,10 +121,15 @@ describe('sidechat.start', () => {
|
||||
expect(options.meta).toMatchObject({
|
||||
parentSession: 'parent',
|
||||
origin: 'subagent',
|
||||
isSeeded: true,
|
||||
delegationDepth: 1,
|
||||
agentPreset: 'preset-a',
|
||||
cwd: '/p',
|
||||
})
|
||||
// The fork-marker pair is load-bearing: without it the child's Inbox
|
||||
// replays the parent's seed inbox splices and inherits whatever input sat
|
||||
// unclaimed in the parent (see sidechat-seed-validation.spec.ts).
|
||||
expect(options.inheritedEventCount).toBe(options.seed.length)
|
||||
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
|
||||
// The child carries the parent's completed turns as a verbatim seed,
|
||||
// closed by the durable subagent descriptor (honest catalog citizenship:
|
||||
@@ -221,6 +227,16 @@ describe('sidechat.start', () => {
|
||||
|
||||
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
|
||||
|
||||
// The empty thread carries the same fork-marker pair (isSeeded +
|
||||
// inheritedEventCount = seed length) as the prompted create.
|
||||
const options = services.create.mock.calls[0]![0] as {
|
||||
meta: Record<string, unknown>
|
||||
seed: readonly { type: string }[]
|
||||
inheritedEventCount: number
|
||||
}
|
||||
expect(options.meta.isSeeded).toBe(true)
|
||||
expect(options.inheritedEventCount).toBe(options.seed.length)
|
||||
|
||||
// No prompt yet: the composer owns the first message; the placeholder
|
||||
// label is pinned and the in-progress snapshot is parked for it.
|
||||
expect(child.inject).not.toHaveBeenCalled()
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* not a mock, so the class of bug cannot silently return.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
|
||||
@@ -111,3 +112,115 @@ describe('sidechat seed against the real dsh-session validator', () => {
|
||||
expect(types.at(-1)).toBe('session/end-seed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () => {
|
||||
/**
|
||||
* Regression: the thread-create call originally passed `seed` WITHOUT the
|
||||
* fork-marker pair (`meta.isSeeded: true` + `inheritedEventCount`). A seed
|
||||
* without them is REPLAY history, not an inherited prefix, so the child's
|
||||
* ownEvents() includes the whole seed and its Inbox constructor replays the
|
||||
* parent's `agent/inbox/spliced` events — inheriting whatever input sat
|
||||
* UNCLAIMED in the parent at the click moment (a queued follow-up, or a
|
||||
* tool-result context spliced into next-step between step boundaries of a
|
||||
* long-running turn). The first side prompt then claimed and SENT that stale
|
||||
* message before the boundary + question. These tests run the REAL
|
||||
* dsh-session Session and dsh-agent Inbox.
|
||||
*/
|
||||
const pendingMessage = (id: string, kind: string, text: string) => ({
|
||||
id,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind },
|
||||
})
|
||||
const inboxOver = (session: Session): { nextTurn: number; nextStep: number } => {
|
||||
const inbox = new Inbox(session, { inserted() {}, discarded() {}, claimed() {} })
|
||||
return { nextTurn: inbox.nextTurn.length, nextStep: inbox.nextStep.length }
|
||||
}
|
||||
/** The shape the routes pass since the fix: isSeeded + inheritedEventCount. */
|
||||
const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session =>
|
||||
Session.create(
|
||||
id as SessionId,
|
||||
seed as never,
|
||||
{ version: 0, id: id as SessionId, createdAt: Date.now(), isSeeded: true },
|
||||
SessionLogOffset(seed.length),
|
||||
)
|
||||
/** Real loop order: inbox insert → turn/start → claim deletion → step/start
|
||||
* → user/message. One completed, fully claimed turn (seq 0-7). */
|
||||
const completedTurnLog = (): SidebarSessionEvent[] => [
|
||||
ev('agent/inbox/spliced', 0, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q', 'user', 'q')] }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('agent/inbox/spliced', 2, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
|
||||
ev('step/start', 3, { turn: 1, step: 1 }),
|
||||
ev('user/message', 4, pendingMessage('m-q', 'user', 'q')),
|
||||
ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a') }),
|
||||
ev('step/end', 6, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
|
||||
]
|
||||
/** Mid-turn with an UNCLAIMED tool-result context in next-step (seq 15). */
|
||||
const midTurnLog = (): SidebarSessionEvent[] => [
|
||||
...completedTurnLog(),
|
||||
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-q2', 'user', 'q2')] }),
|
||||
ev('turn/start', 9, { turn: 2 }),
|
||||
ev('agent/inbox/spliced', 10, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
|
||||
ev('step/start', 11, { turn: 2, step: 1 }),
|
||||
ev('user/message', 12, pendingMessage('m-q2', 'user', 'q2')),
|
||||
ev('tool/call', 13, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }),
|
||||
ev('tool/result', 14, {
|
||||
turn: 2, step: 1,
|
||||
message: {
|
||||
id: 'm-r',
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
},
|
||||
}),
|
||||
ev('agent/inbox/spliced', 15, {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
inserted: [pendingMessage('m-ctx', 'tool', 'tool bash finished: exit 0 ... now continue the plan')],
|
||||
}),
|
||||
]
|
||||
|
||||
it('inherits the parent\'s UNCLAIMED next-step tool context without the fork markers (root cause)', () => {
|
||||
// Parent mid-turn (user queue empty): the bash tool returned and spliced
|
||||
// its follow-up context into next-step; the next step boundary has not
|
||||
// claimed it yet. The reported long-context scenario.
|
||||
const { seed } = buildSidechatInheritance(midTurnLog())
|
||||
// OLD call shape (seed only): the whole seed counts as own events and the
|
||||
// phantom context is inherited as pending input.
|
||||
const unmarked = Session.create('session-inbox-unmarked' as SessionId, seed as never)
|
||||
expect(unmarked.ownEvents().length).toBe(seed.length + 1)
|
||||
expect(inboxOver(unmarked)).toEqual({ nextTurn: 0, nextStep: 1 })
|
||||
})
|
||||
|
||||
it('inherits the parent\'s QUEUED follow-up (next-turn) without the fork markers', () => {
|
||||
const queued = pendingMessage('m-queued', 'user', 'queued second message')
|
||||
const log: SidebarSessionEvent[] = [
|
||||
...completedTurnLog(),
|
||||
ev('agent/inbox/spliced', 8, { target: 'next-turn', start: 0, inserted: [queued] }),
|
||||
]
|
||||
const { seed } = buildSidechatInheritance(log)
|
||||
const unmarked = Session.create('session-inbox-unmarked-turn' as SessionId, seed as never)
|
||||
expect(inboxOver(unmarked)).toEqual({ nextTurn: 1, nextStep: 0 })
|
||||
})
|
||||
|
||||
it('the fork-marker pair (isSeeded + inheritedEventCount) leaves the inherited inbox empty', () => {
|
||||
// Same mid-turn log as the root-cause case, plus the descriptor the
|
||||
// routes append — exactly what sidechat.start passes to agents.create.
|
||||
const { seed } = buildSidechatInheritance(midTurnLog())
|
||||
const withDescriptor = [
|
||||
...seed,
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: seed.length,
|
||||
time: Date.now(),
|
||||
data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }),
|
||||
} as unknown as SidebarSessionEvent,
|
||||
]
|
||||
const marked = forkMarkedSession('session-inbox-marked', withDescriptor)
|
||||
// Only the end-seed marker is own; the transcript cut is unchanged.
|
||||
expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed'])
|
||||
expect(marked.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed')
|
||||
expect(inboxOver(marked)).toEqual({ nextTurn: 0, nextStep: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,3 +309,61 @@ describe('TabBar pin submenu (v0.17.0)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabBar context menu flip geometry (submenu clamping)', () => {
|
||||
const ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
/** jsdom's default viewport (what the hook reads via window.innerWidth/Height). */
|
||||
const VW = window.innerWidth
|
||||
const VH = window.innerHeight
|
||||
|
||||
it('publishes "down" while the menu is open and clears it on selection', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
// rightClick() dispatches at clientY 40 — the top strip, upper half.
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('flips left when the cursor sits within the right-hand submenu room', () => {
|
||||
stubZh()
|
||||
const { tabEls, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: VW - 10, clientY: 40 })
|
||||
act(() => { tabEls[1]!.dispatchEvent(nearRight) })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down left')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the primitive defaults (empty tokens) for a lower-half opening', () => {
|
||||
stubZh()
|
||||
const { tabEls, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
const lowLeft = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: VH - 40 })
|
||||
act(() => { tabEls[1]!.dispatchEvent(lowLeft) })
|
||||
// Attribute present but empty: layout.css matches no token, so the
|
||||
// host primitive's own up/right geometry applies.
|
||||
expect(document.body.getAttribute(ATTR)).toBe('')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the attribute on unmount with the menu still open', () => {
|
||||
stubZh()
|
||||
const { tabEls, unmount } = mountBar(fourTabs())
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
expect(document.body.getAttribute(ATTR)).toBe('down')
|
||||
unmount()
|
||||
expect(document.body.hasAttribute(ATTR)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user