merge main into fix/multi-repository-git-selector
This commit is contained in:
@@ -94,6 +94,9 @@ describe('built-in tab registrations', () => {
|
||||
const options = toggles[0]?.options ?? []
|
||||
expect(options.map(o => o.value)).toEqual([true, false])
|
||||
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
|
||||
// The open-with configuration (SSH host + custom editors) is the custom
|
||||
// panel rendered below the declarative rows.
|
||||
expect(service.getTab('editor')?.settings?.render).toBeDefined()
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { DiffView } from '../src/client/DiffView.tsx'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-a',
|
||||
'+new-a',
|
||||
'diff --git a/tests/b.spec.ts b/tests/b.spec.ts',
|
||||
'--- a/tests/b.spec.ts',
|
||||
'+++ b/tests/b.spec.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-b',
|
||||
'+new-b',
|
||||
'diff --git a/README.md b/README.md',
|
||||
'--- a/README.md',
|
||||
'+++ b/README.md',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-doc',
|
||||
'+new-doc',
|
||||
].join('\n')
|
||||
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('DiffView file folding', () => {
|
||||
it('expands source by default while tests and docs stay collapsed', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
act(() => { root.render(createElement(DiffView, { diff })) })
|
||||
const headers = [...container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')]
|
||||
expect(headers).toHaveLength(3)
|
||||
expect(headers.map(header => header.getAttribute('aria-expanded'))).toEqual(['true', 'false', 'false'])
|
||||
expect(container.textContent).toContain('new-a')
|
||||
expect(container.textContent).not.toContain('new-b')
|
||||
expect(container.textContent).not.toContain('new-doc')
|
||||
|
||||
act(() => { headers[1]!.click() })
|
||||
expect(headers[1]!.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(container.textContent).toContain('new-b')
|
||||
|
||||
act(() => { headers[0]!.click() })
|
||||
expect(headers[0]!.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(container.textContent).not.toContain('new-a')
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Toggle-layout lane (issue #315): while the right panel slides open/closed
|
||||
* with its 300ms layout push, the BOTTOM panel must keep tracking the center
|
||||
* column's horizontal edges frame by frame.
|
||||
*
|
||||
* The shell changed the bottom panel's geometry source from React state
|
||||
* (setCenterRect per ResizeObserver frame → full Sidebar re-render at
|
||||
* animation cadence) to a ref + direct inline writes, so the panel still
|
||||
* follows the animated center column with zero React work. This lane samples
|
||||
* the transition and asserts the bottom panel's edges stay glued to the
|
||||
* center column (left/right within a rounding + one-frame tolerance) — the
|
||||
* visual-equivalence proof for that change.
|
||||
*
|
||||
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
|
||||
* page (own workspace, like drag-layout.e2e.ts).
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace')
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-toggle-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-toggle-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api?.dispose()
|
||||
})
|
||||
|
||||
interface FrameSample {
|
||||
t: number
|
||||
centerLeft: number
|
||||
centerRight: number
|
||||
bottomLeft: number
|
||||
bottomRight: number
|
||||
}
|
||||
|
||||
test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// Dismiss onboarding (same dance as the drag lane).
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e-toggle] no onboarding takeover appeared; proceeding')
|
||||
}
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of ['Continue', 'Configure later']) {
|
||||
const button = page.getByRole('button', { name, exact: true }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
try {
|
||||
await button.click({ timeout: 4_000 })
|
||||
dismissed = true
|
||||
await page.waitForTimeout(800)
|
||||
} catch {
|
||||
// Masked by a takeover stacked above; retry in the next round.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// Open the right panel (fresh sessions start collapsed).
|
||||
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
await expect(expandButton).toHaveCount(1)
|
||||
await expandButton.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return value !== '' && value !== '0px'
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(true)
|
||||
|
||||
// Open the bottom panel too: the twin panels make the toggle exercise the
|
||||
// full geometry chain (bottom panel edges follow the animated push).
|
||||
const expandBottom = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
await expect(expandBottom).toHaveCount(1)
|
||||
await expandBottom.click()
|
||||
// Wait until the bottom panel is REALLY tracking the center column (not
|
||||
// the hidden {0,0} fallback): the panel's box exists even while hidden, so
|
||||
// wait for the computed visibility + a matched right edge before sampling.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
const bottom = document.querySelector('[data-dsh-bottom-panel]')
|
||||
if (col === null || col === undefined || bottom === null) return false
|
||||
const cr = col.getBoundingClientRect()
|
||||
const br = bottom.getBoundingClientRect()
|
||||
const visible = getComputedStyle(bottom).visibility !== 'hidden'
|
||||
return cr.width > 100 && visible && Math.abs(br.right - cr.right) <= 3
|
||||
}), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
await page.waitForTimeout(500) // let both open transitions settle
|
||||
|
||||
// Per-frame sampler: center column (same anchor as layout.css) vs the
|
||||
// bottom panel's visible edges. The loop re-resolves
|
||||
// window.__toggleSamples every frame, so clearing it (sampleToggle) drops
|
||||
// old frames without detaching the loop.
|
||||
await page.evaluate(() => {
|
||||
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
const bottom = document.querySelector('[data-dsh-bottom-panel]')
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
const loop = (): void => {
|
||||
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
||||
const b = bottom?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples.push({
|
||||
t: performance.now(), centerLeft: c.left, centerRight: c.right, bottomLeft: b.left, bottomRight: b.right,
|
||||
})
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
})
|
||||
|
||||
const collapseButton = sidebar.getByRole('button', { name: 'Collapse sidebar' })
|
||||
const expandButton2 = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
|
||||
async function sampleToggle(click: () => Promise<void>): Promise<FrameSample[]> {
|
||||
// Clear accumulated frames so the sample window starts at the click.
|
||||
await page.evaluate(() => {
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
})
|
||||
await click()
|
||||
await page.waitForTimeout(800)
|
||||
const samples = await page.evaluate(() => {
|
||||
const data = (window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
return data
|
||||
})
|
||||
return samples
|
||||
}
|
||||
|
||||
function trackMiss(samples: FrameSample[], label: string): void {
|
||||
// The bottom panel's geometry rides the center column's ResizeObserver:
|
||||
// RO callbacks run AFTER layout, so a write there lands in the NEXT
|
||||
// frame's paint — the panel is exactly ONE animation frame behind the
|
||||
// column mid-transition (measured: bottom(t) === center(t-1)).
|
||||
// Anything more is a real desync; the settled frames must agree exactly.
|
||||
const valid = samples.filter(s => s.centerRight - s.centerLeft > 100)
|
||||
expect(valid.length, `${label}: sampler must have captured the transition`).toBeGreaterThan(3)
|
||||
|
||||
let maxOneFrame = 0
|
||||
for (let i = 1; i < valid.length; i++) {
|
||||
maxOneFrame = Math.max(
|
||||
maxOneFrame,
|
||||
Math.abs(valid[i]!.bottomLeft - valid[i - 1]!.centerLeft),
|
||||
Math.abs(valid[i]!.bottomRight - valid[i - 1]!.centerRight),
|
||||
)
|
||||
}
|
||||
const settled = valid.slice(-5)
|
||||
const settleMiss = Math.max(
|
||||
...settled.map(s => Math.max(
|
||||
Math.abs(s.bottomLeft - s.centerLeft),
|
||||
Math.abs(s.bottomRight - s.centerRight),
|
||||
)),
|
||||
)
|
||||
console.log(`[e2e-toggle] ${label}: ${valid.length} frames, max one-frame miss=${maxOneFrame.toFixed(1)}px, settle miss=${settleMiss.toFixed(1)}px`)
|
||||
expect(maxOneFrame, `${label}: bottom panel must track the center column one frame behind (RO cadence)`).toBeLessThanOrEqual(4)
|
||||
expect(settleMiss, `${label}: bottom panel must settle exactly on the center column`).toBeLessThanOrEqual(3)
|
||||
}
|
||||
|
||||
const collapseSamples = await sampleToggle(() => collapseButton.click())
|
||||
trackMiss(collapseSamples, 'collapse')
|
||||
|
||||
const expandSamples = await sampleToggle(() => expandButton2.click())
|
||||
trackMiss(expandSamples, 'expand')
|
||||
})
|
||||
@@ -219,7 +219,7 @@ describe('EditorHost (files window)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
|
||||
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', async () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
@@ -232,10 +232,15 @@ describe('EditorHost (files window)', () => {
|
||||
// Drag the left edge LEFT by 100px → the right-docked panel widens.
|
||||
// Pointer capture keeps move/up on the handle (jsdom: MouseEvent with
|
||||
// pointer* type names; setPointerCapture is absent and skipped).
|
||||
// Moves are batched to one application per frame (#315), so flush the
|
||||
// pending frame before asserting the width.
|
||||
act(() => {
|
||||
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
|
||||
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
|
||||
})
|
||||
await act(async () => {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
expect(dock.style.width).toBe('340px')
|
||||
// Release: the drag state clears and the width persists on the tab.
|
||||
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) })
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* FileTree "open with" context menu: a file row's right-click menu gains the
|
||||
* pinned direct rows and the parent submenu row; the submenu lists every
|
||||
* resolved target with a per-row pushpin, pinning never selects/closes the
|
||||
* menu, and selecting a child invokes the caller's open handler with the
|
||||
* row's absolute path. When the caller wires nothing, the section is absent.
|
||||
*/
|
||||
// @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 type { OpenWithTarget } from '../src/client/open-with.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
// 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('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
|
||||
}),
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
}))
|
||||
|
||||
/** The resolved target list a caller (EditorHost) would hand FileTree. */
|
||||
const targets: OpenWithTarget[] = [
|
||||
{ id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'vscode', nameKey: 'openWithVscode', name: '', kind: 'url', urlTemplate: 'vscode://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'cursor', nameKey: 'openWithCursor', name: '', kind: 'url', urlTemplate: 'cursor://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'zed', nameKey: 'openWithZed', name: '', kind: 'url', urlTemplate: 'zed://file/{path}', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false },
|
||||
]
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
onOpenWith: ReturnType<typeof vi.fn>
|
||||
onToggleOpenWithPin: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
async function mountTree(overrides: {
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
} = {}): Promise<Harness> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const onOpenWith = vi.fn()
|
||||
const onToggleOpenWithPin = vi.fn()
|
||||
// Property-presence semantics: an explicit `openWithTargets: undefined`
|
||||
// must reach FileTree (the section hides) instead of falling back.
|
||||
const has = (key: 'openWithTargets' | 'openWithPinned' | 'openWithSsh'): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(overrides, key)
|
||||
await act(async () => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onOpenFileNewTab: () => {},
|
||||
onOpenFileSide: () => {},
|
||||
openWithTargets: has('openWithTargets') ? overrides.openWithTargets : targets,
|
||||
openWithPinned: has('openWithPinned') ? overrides.openWithPinned : ['vscode'],
|
||||
openWithSsh: has('openWithSsh') ? overrides.openWithSsh : false,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
onReferenceFile: () => {},
|
||||
refreshTick: 0,
|
||||
onUploadRequest: () => {},
|
||||
busy: false,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
/** The file row of the one-level tree (role="button" with the name span). */
|
||||
function fileRow(container: HTMLDivElement): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
|
||||
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
|
||||
if (row === undefined) throw new Error('file row not found')
|
||||
return row
|
||||
}
|
||||
|
||||
/** Open the row's context menu at a fixed cursor position. */
|
||||
function openMenu(container: HTMLDivElement): void {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
|
||||
act(() => { fileRow(container).dispatchEvent(event) })
|
||||
}
|
||||
|
||||
describe('FileTree open-with menu', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the pinned direct row and the submenu parent for a file row', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
// The pinned VS Code sits at the top level (before the submenu parent).
|
||||
expect(items.map(item => item.textContent?.trim())).toContain('VS Code')
|
||||
const parent = items.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent?.textContent).toContain('Open with')
|
||||
// The submenu parent carries the trailing chevron affordance (the
|
||||
// primitives Menu renders no arrow of its own), right-aligned inside a
|
||||
// full-width label row — the same structure the submenu children use.
|
||||
expect(parent?.querySelector('[class*="openWithChevron"]')).not.toBeNull()
|
||||
expect(parent?.querySelector('[class*="openWithLabel"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens the submenu on click and lists every target with pin toggles', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent).toBeDefined()
|
||||
act(() => { parent!.click() })
|
||||
// Sub child rows = menuitems inside the nested menu.
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
|
||||
])
|
||||
expect(children.every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
|
||||
// Pinned state is announced per row (and swaps the pushpin glyph).
|
||||
const vscodeRow = children.find(item => item.textContent?.trim() === 'VS Code')
|
||||
const cursorRow0 = children.find(item => item.textContent?.trim() === 'Cursor')
|
||||
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
|
||||
expect(cursorRow0?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pin click toggles without selecting the row or closing the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const cursorRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Cursor')
|
||||
const pin = cursorRow!.querySelector<HTMLElement>('[class*="openWithPin"]')
|
||||
expect(pin).not.toBeNull()
|
||||
act(() => { pin!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
|
||||
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('cursor')
|
||||
expect(harness.onOpenWith).not.toHaveBeenCalled()
|
||||
// The menu (and the submenu) stayed open.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('selecting a submenu child invokes onOpenWith with the row path and closes the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
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(harness.onOpenWith).toHaveBeenCalledWith('zed', '/tmp/a.ts')
|
||||
// Selecting closes the row menu entirely.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).toBeNull()
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
|
||||
harness = await mountTree({
|
||||
openWithSsh: true,
|
||||
openWithTargets: targets.filter(target => !target.localOnly),
|
||||
openWithPinned: [],
|
||||
})
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
|
||||
])
|
||||
})
|
||||
|
||||
it('hides the whole section when the caller wires no targets', async () => {
|
||||
harness = await mountTree({ openWithTargets: undefined })
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false)
|
||||
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Frame-batcher: coalesce bursty schedules into one task per animation
|
||||
* frame, flush synchronously on release, and drop pending work on dispose.
|
||||
* Uses a deterministic rAF stub so the coalescing contract is exact.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFrameBatcher } from '../src/client/frame-batcher.ts'
|
||||
|
||||
let frames: Map<number, () => void>
|
||||
let nextId: number
|
||||
|
||||
function stepFrame(): void {
|
||||
const callbacks = [...frames.values()]
|
||||
frames.clear()
|
||||
for (const callback of callbacks) callback()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frames = new Map()
|
||||
nextId = 1
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
frames.set(nextId, callback)
|
||||
return nextId++
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createFrameBatcher', () => {
|
||||
it('coalesces many schedules into one task per frame (latest wins)', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
let pending = 0
|
||||
const apply = (value: number): void => { calls.push(value) }
|
||||
|
||||
// Caller pattern: compose into a ref, schedule a reader of that ref.
|
||||
for (const value of [1, 2, 3, 4]) {
|
||||
pending = value
|
||||
batcher.schedule(() => apply(pending))
|
||||
}
|
||||
expect(calls).toEqual([])
|
||||
stepFrame()
|
||||
expect(calls).toEqual([4])
|
||||
expect(frames.size).toBe(0)
|
||||
|
||||
// The next burst starts a fresh frame.
|
||||
pending = 7
|
||||
batcher.schedule(() => apply(pending))
|
||||
stepFrame()
|
||||
expect(calls).toEqual([4, 7])
|
||||
})
|
||||
|
||||
it('flushNow runs the pending task synchronously and cancels the frame', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
batcher.schedule(() => calls.push(1))
|
||||
batcher.schedule(() => calls.push(2))
|
||||
expect(frames.size).toBe(1) // still one pending frame
|
||||
|
||||
batcher.flushNow()
|
||||
expect(calls).toEqual([2]) // latest task, synchronously
|
||||
expect(frames.size).toBe(0) // frame cancelled
|
||||
|
||||
stepFrame() // the cancelled frame must not fire again
|
||||
expect(calls).toEqual([2])
|
||||
|
||||
// flushNow when nothing is scheduled is a no-op.
|
||||
batcher.flushNow()
|
||||
expect(calls).toEqual([2])
|
||||
})
|
||||
|
||||
it('dispose drops the pending task without running it', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
batcher.schedule(() => calls.push(1))
|
||||
batcher.dispose()
|
||||
expect(frames.size).toBe(0)
|
||||
stepFrame()
|
||||
expect(calls).toEqual([])
|
||||
|
||||
// A disposed batcher accepts new schedules (React strict-mode remounts
|
||||
// reuse the same ref-created instance semantics).
|
||||
batcher.schedule(() => calls.push(2))
|
||||
stepFrame()
|
||||
expect(calls).toEqual([2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
|
||||
import { isGitRepo } from '../src/git.ts'
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
describe('git subprocess spawning', () => {
|
||||
it('hides spawned git windows', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new EventEmitter()
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
Object.assign(child, { stdout, stderr, kill: vi.fn() })
|
||||
|
||||
queueMicrotask(() => {
|
||||
stdout.end('true\n')
|
||||
stderr.end()
|
||||
child.emit('close', 0)
|
||||
})
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
await expect(isGitRepo('C:\\repo')).resolves.toBe(true)
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['-C', 'C:\\repo', '--no-pager', '-c', 'color.ui=false', 'rev-parse', '--is-inside-work-tree'],
|
||||
expect.objectContaining({ windowsHide: true }),
|
||||
)
|
||||
})
|
||||
})
|
||||
+32
-1
@@ -1,4 +1,5 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -44,6 +45,36 @@ describe('git parsing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps untracked files inside new directories as individual rows (status --untracked-files=all)', () => {
|
||||
// status() runs with --untracked-files=all, so a new folder must surface
|
||||
// as one entry PER FILE (?? newdir/a.ts), never a collapsed ?? newdir/
|
||||
// row that has no diff and cannot be read (regression: new folders showed
|
||||
// as a single folder row whose diff tab failed with "is a directory").
|
||||
const output = ['?? newdir/a.ts', '?? newdir/sub/b.ts', ''].join('\0')
|
||||
expect(parsePorcelainZ(output)).toEqual([
|
||||
{ path: 'newdir/a.ts', xy: '??' },
|
||||
{ path: 'newdir/sub/b.ts', xy: '??' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps untracked files inside new directories as individual rows (real git)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-git-status-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '-q'], { cwd: root })
|
||||
execFileSync('git', ['config', 'user.email', 't@t'], { cwd: root })
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: root })
|
||||
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: root })
|
||||
mkdirSync(join(root, 'newdir'))
|
||||
writeFileSync(join(root, 'newdir', 'a.ts'), 'x')
|
||||
const result = await status(root)
|
||||
const paths = result.entries.map(entry => entry.path)
|
||||
expect(paths).toContain('newdir/a.ts')
|
||||
expect(paths.some(path => path.endsWith('/'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses log rows with unit separators (full hash + refs)', () => {
|
||||
const rows = parseLogLines(
|
||||
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Host external-open helpers: per-platform opener commands (pure, injected
|
||||
* platform) and the URL validation that guards the spawn route. The actual
|
||||
* spawns are not exercised — the route runs in the DSH host process and the
|
||||
* OS outcome is not testable here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { launchExternal, revealCommand, urlCommand, validateExternalUrl } from '../src/open-external.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
|
||||
describe('revealCommand', () => {
|
||||
it('darwin: `open -R <path>` selects the file in Finder', () => {
|
||||
expect(revealCommand('/a/b.txt', 'darwin')).toEqual({ command: 'open', args: ['-R', '/a/b.txt'] })
|
||||
})
|
||||
|
||||
it('win32: `explorer /select,<path>` selects the file', () => {
|
||||
expect(revealCommand('C:\\a\\b.txt', 'win32')).toEqual({ command: 'explorer.exe', args: ['/select,', 'C:\\a\\b.txt'] })
|
||||
})
|
||||
|
||||
it('linux: `xdg-open` opens the containing directory (no common select protocol)', () => {
|
||||
expect(revealCommand('/a/b.txt', 'linux')).toEqual({ command: 'xdg-open', args: ['/a'] })
|
||||
expect(revealCommand('/', 'linux')).toEqual({ command: 'xdg-open', args: ['/'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlCommand', () => {
|
||||
it('darwin: `open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('vscode://file/x', 'darwin')).toEqual({ command: 'open', args: ['vscode://file/x'] })
|
||||
})
|
||||
|
||||
it('win32: rundll32 url.dll,FileProtocolHandler <url>', () => {
|
||||
expect(urlCommand('cursor://file/x', 'win32')).toEqual({
|
||||
command: 'rundll32.exe',
|
||||
args: ['url.dll,FileProtocolHandler', 'cursor://file/x'],
|
||||
})
|
||||
})
|
||||
|
||||
it('linux: `xdg-open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('zed://file/x', 'linux')).toEqual({ command: 'xdg-open', args: ['zed://file/x'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExternalUrl', () => {
|
||||
it('accepts custom-scheme URLs (incl. the SSH-remote form)', () => {
|
||||
expect(validateExternalUrl('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts'))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(validateExternalUrl('myapp://file/{path}')).toBe('myapp://file/{path}')
|
||||
})
|
||||
|
||||
it('rejects http/https (only custom schemes make sense here)', () => {
|
||||
expect(() => validateExternalUrl('https://example.com')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('http://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects non-URL / non-`scheme://` strings', () => {
|
||||
expect(() => validateExternalUrl('/home/u/f.ts')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('a:file/x')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchExternal validation (pre-spawn)', () => {
|
||||
it('rejects relative reveal paths before anything is spawned', () => {
|
||||
expect(() => launchExternal('reveal', 'relative/path')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects invalid URLs before anything is spawned', () => {
|
||||
expect(() => launchExternal('url', 'https://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The editor's "open with" settings panel: the SSH host row and the custom
|
||||
* editor list (name + URL template + VSCode-family flag, add/remove). Every
|
||||
* edit commits the whole `openWith` blob through the popup's
|
||||
* updatePluginSetting; removing an editor also prunes its pinned id.
|
||||
*/
|
||||
// @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 { OpenWithSettings } from '../src/client/open-with-settings.tsx'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
update: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountSettings(rawOpenWith: unknown): Harness {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const update = vi.fn()
|
||||
act(() => {
|
||||
root.render(createElement(OpenWithSettings, {
|
||||
pluginSettings: { openWith: rawOpenWith },
|
||||
updatePluginSetting: update,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
update,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
],
|
||||
pinned: ['vscode', 'custom:a'],
|
||||
}
|
||||
|
||||
describe('OpenWithSettings', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('seeds the SSH host input from the persisted blob and commits edits', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input:not([type])')
|
||||
expect(input?.value).toBe('dev')
|
||||
// The native value setter (React's value tracker must see the change).
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
||||
act(() => {
|
||||
setter.call(input, 'prod')
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
expect(harness.update.mock.calls[0]?.[0]).toBe('openWith')
|
||||
expect(harness.update.mock.calls[0]?.[1]).toMatchObject({ sshHost: 'prod' })
|
||||
})
|
||||
|
||||
it('adds a custom editor row and commits the extended list', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const add = [...harness.container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent?.trim() === 'Add')
|
||||
expect(add).toBeDefined()
|
||||
act(() => { add!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(2)
|
||||
expect(blob.pinned).toEqual(['vscode', 'custom:a'])
|
||||
})
|
||||
|
||||
it('removes an editor and prunes its pinned id', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const remove = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')
|
||||
expect(remove).toBeDefined()
|
||||
act(() => { remove!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(0)
|
||||
expect(blob.pinned).toEqual(['vscode'])
|
||||
})
|
||||
|
||||
it('shows the validity hint when a row is incomplete', () => {
|
||||
harness = mountSettings({
|
||||
sshHost: '',
|
||||
customEditors: [{ id: 'x', name: 'Bad', urlTemplate: 'x://file/x', isVscodeFamily: true }],
|
||||
pinned: [],
|
||||
})
|
||||
expect(harness.container.textContent).toContain('are not shown in the menu')
|
||||
})
|
||||
|
||||
it('edits the VSCode-family flag of one row', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const checkbox = harness.container.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox?.checked).toBe(false)
|
||||
act(() => { checkbox!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: { isVscodeFamily: boolean }[] }
|
||||
expect(blob.customEditors[0]?.isVscodeFamily).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The file tree "open with" pure logic: config parsing (tolerant), target
|
||||
* resolution (built-ins + custom, SSH filtering), URL building (local file
|
||||
* URLs and VSCode-family SSH-remote URLs) and the custom-editor validators.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
normalizeUrlPath,
|
||||
openWithSshActive,
|
||||
openWithUrl,
|
||||
parseOpenWithConfig,
|
||||
resolveOpenWithTargets,
|
||||
type CustomEditor,
|
||||
type OpenWithTarget,
|
||||
} from '../src/client/open-with.ts'
|
||||
|
||||
describe('parseOpenWithConfig', () => {
|
||||
it('returns the defaults for null / non-object / array values', () => {
|
||||
expect(parseOpenWithConfig(null)).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig('junk')).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig([])).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
})
|
||||
|
||||
it('keeps structurally valid custom editors (incl. in-progress rows) and drops junk', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'b', name: '', urlTemplate: 'b://file/{path}', isVscodeFamily: true },
|
||||
{ id: 'c', name: 'Trae', urlTemplate: 'trae://file/{path}', isVscodeFamily: true },
|
||||
'junk',
|
||||
null,
|
||||
42,
|
||||
],
|
||||
pinned: ['vscode', 'custom:a', 42, '', 'custom:c'],
|
||||
})
|
||||
expect(config.sshHost).toBe('dev')
|
||||
expect(config.customEditors.map(editor => editor.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(config.pinned).toEqual(['vscode', 'custom:a', 'custom:c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOpenWithTargets', () => {
|
||||
const localCustom: CustomEditor = { id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false }
|
||||
const familyCustom: CustomEditor = { id: 'e2', name: 'Zed-like fork', urlTemplate: 'x-zed://file/{path}', isVscodeFamily: true }
|
||||
|
||||
it('lists built-ins first, then custom editors (local mode)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({ customEditors: [localCustom] }))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
expect(targets[0]?.kind).toBe('reveal')
|
||||
expect(targets[1]?.urlTemplate).toBe('vscode://file/{path}')
|
||||
})
|
||||
|
||||
it('excludes incomplete custom-editor rows from the menu (they stay in the blob)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({
|
||||
customEditors: [
|
||||
{ id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'e2', name: '', urlTemplate: 'incomplete://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
}))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
})
|
||||
|
||||
it('drops local-only targets in SSH mode (file manager, Zed, non-VSCode custom)', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev', customEditors: [localCustom, familyCustom] })
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(targets.map(target => target.id)).toEqual(['vscode', 'cursor', 'custom:e2'])
|
||||
})
|
||||
|
||||
it('prunes unknown pinned ids only when resolving (the parse keeps them)', () => {
|
||||
const config = parseOpenWithConfig({ pinned: ['ghost'] })
|
||||
expect(config.pinned).toEqual(['ghost'])
|
||||
expect(resolveOpenWithTargets(config).map(target => target.id)).toContain('vscode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWithUrl', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({}))
|
||||
const byId = (id: string): OpenWithTarget => {
|
||||
const target = targets.find(item => item.id === id)
|
||||
if (target === undefined) throw new Error(`no target ${id}`)
|
||||
return target
|
||||
}
|
||||
|
||||
it('builds local file URLs with the raw path (backslashes normalized)', () => {
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', parseOpenWithConfig({}))).toBe('vscode://file//home/u/f.ts')
|
||||
expect(openWithUrl(byId('vscode'), 'C:\\Users\\u\\f.ts', parseOpenWithConfig({}))).toBe('vscode://file/C:/Users/u/f.ts')
|
||||
expect(openWithUrl(byId('zed'), '/x/y', parseOpenWithConfig({}))).toBe('zed://file//x/y')
|
||||
})
|
||||
|
||||
it('builds SSH-remote URLs for VSCode-family targets in SSH mode', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev' })
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', config))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(openWithUrl(byId('cursor'), '/home/u/f.ts', config))
|
||||
.toBe('cursor://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
})
|
||||
|
||||
it('keeps the local URL form for non-VSCode-family targets even in SSH mode', () => {
|
||||
// (Such targets are hidden from the SSH menu; the URL builder stays honest.)
|
||||
expect(openWithUrl(byId('zed'), '/x', parseOpenWithConfig({ sshHost: 'dev' }))).toBe('zed://file//x')
|
||||
})
|
||||
|
||||
it('returns undefined for reveal targets and malformed templates', () => {
|
||||
expect(openWithUrl(byId('explorer'), '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noScheme: OpenWithTarget = { id: 'x', name: '', kind: 'url', urlTemplate: 'not-a-url {path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noScheme, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noSlash: OpenWithTarget = { id: 'y', name: '', kind: 'url', urlTemplate: 'a:file/{path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noSlash, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the custom template scheme for SSH URLs of VSCode-family custom editors', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'e', name: 'Fork', urlTemplate: 'myfork://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
})
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(openWithUrl(targets[targets.length - 1]!, '/r/f.ts', config))
|
||||
.toBe('myfork://vscode-remote/ssh-remote+dev/r/f.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalizeUrlPath converts backslashes to slashes', () => {
|
||||
expect(normalizeUrlPath('a\\b\\c')).toBe('a/b/c')
|
||||
})
|
||||
|
||||
it('openWithSshActive trims the host', () => {
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' ' }))).toBe(false)
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' dev ' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('isValidCustomEditor requires a name, a {path} placeholder and a scheme:// prefix', () => {
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/{path}' })).toBe(true)
|
||||
expect(isValidCustomEditor({ name: '', urlTemplate: 'a://file/{path}' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/x' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a:file/{path}' })).toBe(false)
|
||||
})
|
||||
|
||||
it('newCustomEditorId yields unique non-empty ids', () => {
|
||||
const first = newCustomEditorId()
|
||||
const second = newCustomEditorId()
|
||||
expect(first).not.toBe(second)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -28,8 +28,9 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import type { TabDescriptor } from '../src/client/service.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SettingsBody, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
@@ -347,3 +348,68 @@ describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () =>
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsBody rows + custom render panel (open-with seam)', () => {
|
||||
it('renders the declarative rows AND the custom panel when both are declared', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto row',
|
||||
desc: () => 'row description',
|
||||
}],
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('Auto row')
|
||||
expect(html).toContain('row description')
|
||||
expect(html).toContain('data-custom-panel')
|
||||
expect(html).toContain('custom panel')
|
||||
})
|
||||
|
||||
it('renders ONLY the custom panel when no rows are declared (unchanged behavior)', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('custom panel')
|
||||
expect(html).not.toContain('Auto row')
|
||||
})
|
||||
})
|
||||
|
||||
+28
-5
@@ -188,6 +188,29 @@ describe('sidebar state', () => {
|
||||
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('sanitize removes a pane emptied by ephemeral diff tabs', () => {
|
||||
const valid = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:diff',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'split',
|
||||
id: 'split:1',
|
||||
dir: 'col',
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: 'leaf', id: 'pane:git', active: 'git', tabs: [{ id: 'git', type: 'git', title: 'Git' }] },
|
||||
{ kind: 'leaf', id: 'pane:diff', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(valid?.splits.kind).toBe('leaf')
|
||||
expect((valid?.splits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git')
|
||||
expect(valid?.activePane).toBe('pane:git')
|
||||
})
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
@@ -716,11 +739,11 @@ describe('persisted state sanitization', () => {
|
||||
const clean = sanitizeState(corrupted)
|
||||
expect(clean).toBeDefined()
|
||||
const leaves = allLeaves(clean!.splits)
|
||||
// The first occurrence keeps its id; the repeat gets a fresh unique one
|
||||
// (exact suffix depends on the module-level uid counter, so assert shape).
|
||||
expect(leaves[0]!.id).toBe('pane:1')
|
||||
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
|
||||
expect(clean!.activePane).toBe(leaves[1]!.id)
|
||||
// The empty first occurrence is pruned; the populated repeat keeps its
|
||||
// fresh unique id and becomes active.
|
||||
expect(leaves).toHaveLength(1)
|
||||
expect(leaves[0]!.id).not.toBe('pane:1')
|
||||
expect(clean!.activePane).toBe(leaves[0]!.id)
|
||||
// And an open must land in exactly one pane of the healed tree.
|
||||
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tab-strip right-click context-menu tests. Right-clicking a tab takes over
|
||||
* the browser menu (preventDefault) and shows the tab context menu with
|
||||
* exactly four items: close / close others / close to the left / close to
|
||||
* the right. All bulk operations are scoped to the CURRENT pane (the render
|
||||
* time tab snapshot) and reuse the per-tab onClose path, so the target tab
|
||||
* is never closed and the pane never empties mid-loop. The menu items gray
|
||||
* out when there is nothing to close (single tab → close others; leftmost →
|
||||
* close left; rightmost → close right). Opening the menu must not activate
|
||||
* the right-clicked tab.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { 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'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { TabBar } from '../src/client/TabBar.tsx'
|
||||
import type { SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
/** Point the browser-language fallback at Chinese so the menu labels assert. */
|
||||
function stubZh(): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { language: 'zh-CN' },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
|
||||
|
||||
function mountBar(tabs: SidebarTab[]): {
|
||||
tabEls: HTMLElement[]
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const onClose = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const root: Root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(TabBar, {
|
||||
paneId: 'pane:1',
|
||||
tabs,
|
||||
active: tabs[0]?.id ?? null,
|
||||
onActivate,
|
||||
onClose,
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[]
|
||||
return {
|
||||
tabEls,
|
||||
onClose,
|
||||
onActivate,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fourTabs(): SidebarTab[] {
|
||||
return [
|
||||
{ id: 't1', type: 'editor', title: 'Tab 1' },
|
||||
{ id: 't2', type: 'git', title: 'Tab 2' },
|
||||
{ id: 't3', type: 'terminal', title: 'Tab 3' },
|
||||
{ id: 't4', type: 'browser', title: 'Tab 4' },
|
||||
]
|
||||
}
|
||||
|
||||
/** Dispatch a native right-click (contextmenu) and return the event. */
|
||||
function rightClick(target: EventTarget): MouseEvent {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 40 })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
/** The portaled menu rows (empty when the menu is closed). */
|
||||
function menuItems(): HTMLElement[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TabBar right-click context menu', () => {
|
||||
it('opens the four-item menu at the cursor, prevents the browser menu, and does not activate', () => {
|
||||
stubZh()
|
||||
const { tabEls, onActivate, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
let event: MouseEvent | null = null
|
||||
act(() => { event = rightClick(tabEls[1]!) })
|
||||
expect(event!.defaultPrevented).toBe(true)
|
||||
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close closes only the target tab and closes the menu', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close others closes every tab in the pane except the target, in visual order', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
|
||||
expect(onClose).not.toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close left closes only the tabs to the left of the target', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) })
|
||||
act(() => { menuItems()[2]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close right closes only the tabs to the right of the target', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[3]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('grays out close others on a single tab and close left/right at the strip ends', () => {
|
||||
stubZh()
|
||||
const single = mountBar([
|
||||
{ id: 'only', type: 'editor', title: 'Only' },
|
||||
])
|
||||
try {
|
||||
act(() => { rightClick(single.tabEls[0]!) })
|
||||
const items = menuItems()
|
||||
// The Menu renders each row as a disabled <button role="menuitem">.
|
||||
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
|
||||
// Clicking the disabled row must not close anything.
|
||||
act(() => { items[1]!.click() })
|
||||
expect(single.onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
single.unmount()
|
||||
}
|
||||
|
||||
const four = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(four.tabEls[0]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
|
||||
act(() => { rightClick(four.tabEls[3]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
|
||||
} finally {
|
||||
four.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The TabContent memo comparator: geometry/store re-renders of the Sidebar
|
||||
* shell must not reconcile every mounted tab (issue #315), while the fields
|
||||
* a cell's output or callbacks depend on MUST invalidate it. The pane-move
|
||||
* case (review P1): moveTab reuses the SAME tab object when a tab moves
|
||||
* panes, so paneId — not tab identity — is what identifies the move and
|
||||
* must be compared (onOpenDiff closes over the pane id).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tabContentCompare, type TabContentMemoKey } from '../src/client/tab-content-memo.ts'
|
||||
import type { SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
function makeKey(overrides: Partial<TabContentMemoKey> = {}): TabContentMemoKey {
|
||||
return {
|
||||
tab: { id: 'tab:1', type: 'git', title: 'Git' } as SidebarTab,
|
||||
paneId: 'pane:a',
|
||||
sessionId: 'session:1',
|
||||
cwd: '/workspace',
|
||||
visible: true,
|
||||
expanded: [],
|
||||
localeRevision: 'en-US',
|
||||
tabsVersion: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('tabContentCompare', () => {
|
||||
it('skips re-render when nothing render-affecting changed (callback identity alone is ignored)', () => {
|
||||
const key = makeKey()
|
||||
expect(tabContentCompare(key, { ...key })).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates on a tab moving between panes even though the tab OBJECT is reused (moveTab P1)', () => {
|
||||
const sameTab = { id: 'tab:1', type: 'git', title: 'Git' } as SidebarTab
|
||||
const before = makeKey({ tab: sameTab, paneId: 'pane:a' })
|
||||
const after = makeKey({ tab: sameTab, paneId: 'pane:b' }) // moveTab reinserts the same object
|
||||
expect(before.tab).toBe(after.tab)
|
||||
expect(tabContentCompare(before, after)).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on locale switches (copy freshness)', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ localeRevision: 'zh-CN' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on tab-registry updates (descriptor refresh)', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ tabsVersion: 1 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on visible/expanded/session/cwd changes', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ visible: false }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ expanded: ['/workspace'] }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ sessionId: 'session:2' }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ cwd: '/other' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user