mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
* fix: prevent relay fs.rename from clobbering an existing destination The remote file-explorer rename path (provider.rename -> relay fs.rename) called fs.rename unconditionally, silently overwriting any file/folder already at the destination. The local rename already guards via assertFileExplorerRenameDestinationAvailable; apply the same guard on the relay to restore local/remote parity (case-only renames on case-insensitive filesystems still allowed). Moved the collision helper from src/main to src/shared so both the main process and the remotely-deployed relay share one implementation. Closes #2926 * review: make SSH safe rename explicit - keep relay fs.rename raw and add fs.renameNoClobber for user-facing renames - route SSH file-explorer rename paths through renameNoClobber - add relay/provider/runtime regression coverage and stale-relay fail-closed handling - verified live SSH rename behavior on openclaw 2 Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
/* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs,
|
|
Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { FsHandler } from './fs-handler'
|
|
import { RelayContext } from './context'
|
|
import type { RelayDispatcher } from './dispatcher'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
|
|
const { mockSubscribe } = vi.hoisted(() => ({
|
|
mockSubscribe: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@parcel/watcher', () => ({
|
|
subscribe: mockSubscribe
|
|
}))
|
|
|
|
function createMockDispatcher() {
|
|
const requestHandlers = new Map<
|
|
string,
|
|
(
|
|
params: Record<string, unknown>,
|
|
context?: { clientId: number; isStale: () => boolean }
|
|
) => Promise<unknown>
|
|
>()
|
|
const notificationHandlers = new Map<
|
|
string,
|
|
(
|
|
params: Record<string, unknown>,
|
|
context?: { clientId: number; isStale: () => boolean }
|
|
) => void
|
|
>()
|
|
const detachListeners = new Set<(clientId: number) => void>()
|
|
const notifications: { method: string; params?: Record<string, unknown> }[] = []
|
|
|
|
return {
|
|
onRequest: vi.fn(
|
|
(
|
|
method: string,
|
|
handler: (
|
|
params: Record<string, unknown>,
|
|
context?: { clientId: number; isStale: () => boolean }
|
|
) => Promise<unknown>
|
|
) => {
|
|
requestHandlers.set(method, handler)
|
|
}
|
|
),
|
|
onNotification: vi.fn(
|
|
(
|
|
method: string,
|
|
handler: (
|
|
params: Record<string, unknown>,
|
|
context?: { clientId: number; isStale: () => boolean }
|
|
) => void
|
|
) => {
|
|
notificationHandlers.set(method, handler)
|
|
}
|
|
),
|
|
notify: vi.fn((method: string, params?: Record<string, unknown>) => {
|
|
notifications.push({ method, params })
|
|
}),
|
|
onClientDetached: vi.fn((listener: (clientId: number) => void) => {
|
|
detachListeners.add(listener)
|
|
return () => detachListeners.delete(listener)
|
|
}),
|
|
_requestHandlers: requestHandlers,
|
|
_notificationHandlers: notificationHandlers,
|
|
_notifications: notifications,
|
|
async callRequest(
|
|
method: string,
|
|
params: Record<string, unknown> = {},
|
|
context?: { clientId?: number; isStale: () => boolean }
|
|
) {
|
|
const handler = requestHandlers.get(method)
|
|
if (!handler) {
|
|
throw new Error(`No handler for ${method}`)
|
|
}
|
|
return handler(params, {
|
|
clientId: context?.clientId ?? 1,
|
|
isStale: context?.isStale ?? (() => false)
|
|
})
|
|
},
|
|
callNotification(
|
|
method: string,
|
|
params: Record<string, unknown> = {},
|
|
context?: { clientId: number; isStale: () => boolean }
|
|
) {
|
|
const handler = notificationHandlers.get(method)
|
|
if (!handler) {
|
|
throw new Error(`No handler for ${method}`)
|
|
}
|
|
handler(params, context ?? { clientId: 1, isStale: () => false })
|
|
},
|
|
detachClient(clientId: number) {
|
|
for (const listener of detachListeners) {
|
|
listener(clientId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('FsHandler', () => {
|
|
let dispatcher: ReturnType<typeof createMockDispatcher>
|
|
let handler: FsHandler
|
|
let tmpDir: string
|
|
|
|
beforeEach(() => {
|
|
mockSubscribe.mockReset()
|
|
mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() })
|
|
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-fs-'))
|
|
dispatcher = createMockDispatcher()
|
|
const ctx = new RelayContext()
|
|
handler = new FsHandler(dispatcher as unknown as RelayDispatcher, ctx)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
handler.dispose()
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('registers all expected handlers', () => {
|
|
const methods = Array.from(dispatcher._requestHandlers.keys())
|
|
expect(methods).toContain('fs.readDir')
|
|
expect(methods).toContain('fs.readFile')
|
|
expect(methods).toContain('fs.tempDir')
|
|
expect(methods).toContain('fs.writeFile')
|
|
expect(methods).toContain('fs.stat')
|
|
expect(methods).toContain('fs.deletePath')
|
|
expect(methods).toContain('fs.createFile')
|
|
expect(methods).toContain('fs.createDir')
|
|
expect(methods).toContain('fs.createDirNoClobber')
|
|
expect(methods).toContain('fs.rename')
|
|
expect(methods).toContain('fs.renameNoClobber')
|
|
expect(methods).toContain('fs.copy')
|
|
expect(methods).toContain('fs.realpath')
|
|
expect(methods).toContain('fs.search')
|
|
expect(methods).toContain('fs.listFiles')
|
|
expect(methods).toContain('fs.workspaceSpaceScan')
|
|
expect(methods).toContain('fs.watch')
|
|
|
|
const notifMethods = Array.from(dispatcher._notificationHandlers.keys())
|
|
expect(notifMethods).toContain('fs.unwatch')
|
|
})
|
|
|
|
it('tempDir returns the relay host temp directory', async () => {
|
|
await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir())
|
|
})
|
|
|
|
it('readDir returns sorted entries with directories first', async () => {
|
|
mkdirSync(path.join(tmpDir, 'subdir'))
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world')
|
|
|
|
const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as {
|
|
name: string
|
|
isDirectory: boolean
|
|
}[]
|
|
expect(result[0].name).toBe('subdir')
|
|
expect(result[0].isDirectory).toBe(true)
|
|
expect(result.find((e) => e.name === 'file.txt')).toBeDefined()
|
|
expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined()
|
|
})
|
|
|
|
it('readDir reports symlinked directories as directories', async () => {
|
|
const targetDir = path.join(tmpDir, 'external-models')
|
|
const linkPath = path.join(tmpDir, 'Model')
|
|
mkdirSync(targetDir)
|
|
symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
|
const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as {
|
|
name: string
|
|
isDirectory: boolean
|
|
isSymlink: boolean
|
|
}[]
|
|
|
|
expect(result.find((e) => e.name === 'Model')).toEqual({
|
|
name: 'Model',
|
|
isDirectory: true,
|
|
isSymlink: true
|
|
})
|
|
})
|
|
|
|
it('readFile returns text content for text files', async () => {
|
|
const filePath = path.join(tmpDir, 'test.txt')
|
|
writeFileSync(filePath, 'hello world')
|
|
|
|
const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as {
|
|
content: string
|
|
isBinary: boolean
|
|
}
|
|
expect(result.content).toBe('hello world')
|
|
expect(result.isBinary).toBe(false)
|
|
})
|
|
|
|
it('readFile returns text files larger than the old 5MB guard', async () => {
|
|
const filePath = path.join(tmpDir, 'large.json')
|
|
const content = 'a'.repeat(6 * 1024 * 1024)
|
|
writeFileSync(filePath, content)
|
|
|
|
const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as {
|
|
content: string
|
|
isBinary: boolean
|
|
}
|
|
expect(result.content).toBe(content)
|
|
expect(result.isBinary).toBe(false)
|
|
})
|
|
|
|
it('readFile returns binary marker for large unknown binary files', async () => {
|
|
const filePath = path.join(tmpDir, 'archive.bin')
|
|
const content = Buffer.alloc(6 * 1024 * 1024, 0x61)
|
|
content[0] = 0x00
|
|
writeFileSync(filePath, content)
|
|
|
|
const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as {
|
|
content: string
|
|
isBinary: boolean
|
|
}
|
|
expect(result.content).toBe('')
|
|
expect(result.isBinary).toBe(true)
|
|
})
|
|
|
|
it('readFile returns base64 for image files', async () => {
|
|
const filePath = path.join(tmpDir, 'test.png')
|
|
writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
|
|
const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as {
|
|
content: string
|
|
isBinary: boolean
|
|
isImage: boolean
|
|
mimeType: string
|
|
}
|
|
expect(result.isBinary).toBe(true)
|
|
expect(result.isImage).toBe(true)
|
|
expect(result.mimeType).toBe('image/png')
|
|
expect(result.content).toBeTruthy()
|
|
})
|
|
|
|
it('readFile throws for files exceeding size limit', async () => {
|
|
const filePath = path.join(tmpDir, 'huge.txt')
|
|
writeFileSync(filePath, Buffer.alloc(11 * 1024 * 1024, 'a'))
|
|
|
|
await expect(dispatcher.callRequest('fs.readFile', { filePath })).rejects.toThrow(
|
|
'File too large'
|
|
)
|
|
})
|
|
|
|
it('writeFile creates/overwrites file content', async () => {
|
|
const filePath = path.join(tmpDir, 'write-test.txt')
|
|
await dispatcher.callRequest('fs.writeFile', { filePath, content: 'new content' })
|
|
|
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
expect(content).toBe('new content')
|
|
})
|
|
|
|
it('stat returns file metadata', async () => {
|
|
const filePath = path.join(tmpDir, 'stat-test.txt')
|
|
writeFileSync(filePath, 'test')
|
|
|
|
const result = (await dispatcher.callRequest('fs.stat', { filePath })) as {
|
|
size: number
|
|
type: string
|
|
mtime: number
|
|
}
|
|
expect(result.type).toBe('file')
|
|
expect(result.size).toBe(4)
|
|
expect(typeof result.mtime).toBe('number')
|
|
})
|
|
|
|
it('stat returns directory type for directories', async () => {
|
|
const result = (await dispatcher.callRequest('fs.stat', { filePath: tmpDir })) as {
|
|
type: string
|
|
}
|
|
expect(result.type).toBe('directory')
|
|
})
|
|
|
|
it('stat returns directory type for symlinked directories', async () => {
|
|
const targetDir = path.join(tmpDir, 'external-models')
|
|
const linkPath = path.join(tmpDir, 'Model')
|
|
mkdirSync(targetDir)
|
|
symlinkSync(targetDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
|
const result = (await dispatcher.callRequest('fs.stat', { filePath: linkPath })) as {
|
|
type: string
|
|
}
|
|
|
|
expect(result.type).toBe('directory')
|
|
})
|
|
|
|
it('lstat returns symlink type without following links', async () => {
|
|
const targetFile = path.join(tmpDir, 'target.txt')
|
|
const linkPath = path.join(tmpDir, 'link.txt')
|
|
writeFileSync(targetFile, 'target')
|
|
symlinkSync(targetFile, linkPath)
|
|
|
|
const result = (await dispatcher.callRequest('fs.lstat', { filePath: linkPath })) as {
|
|
type: string
|
|
}
|
|
|
|
expect(result.type).toBe('symlink')
|
|
})
|
|
|
|
it('workspaceSpaceScan returns bounded top-level size details', async () => {
|
|
mkdirSync(path.join(tmpDir, 'node_modules'))
|
|
writeFileSync(path.join(tmpDir, 'node_modules', 'pkg.js'), Buffer.alloc(512))
|
|
writeFileSync(path.join(tmpDir, 'file.log'), Buffer.alloc(128))
|
|
|
|
const result = (await dispatcher.callRequest(
|
|
'fs.workspaceSpaceScan',
|
|
{ rootPath: tmpDir },
|
|
{ isStale: () => false }
|
|
)) as {
|
|
sizeBytes: number
|
|
topLevelItems: { name: string; sizeBytes: number }[]
|
|
}
|
|
|
|
expect(result.sizeBytes).toBeGreaterThanOrEqual(640)
|
|
expect(result.topLevelItems.map((item) => item.name)).toContain('node_modules')
|
|
expect(result.topLevelItems.map((item) => item.name)).toContain('file.log')
|
|
})
|
|
|
|
it('deletePath removes files', async () => {
|
|
const filePath = path.join(tmpDir, 'to-delete.txt')
|
|
writeFileSync(filePath, 'bye')
|
|
|
|
await dispatcher.callRequest('fs.deletePath', { targetPath: filePath })
|
|
await expect(fs.access(filePath)).rejects.toThrow()
|
|
})
|
|
|
|
it('createFile creates an empty file with parent dirs', async () => {
|
|
const filePath = path.join(tmpDir, 'deep', 'nested', 'file.txt')
|
|
await dispatcher.callRequest('fs.createFile', { filePath })
|
|
|
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
expect(content).toBe('')
|
|
})
|
|
|
|
it('createDir creates directories recursively', async () => {
|
|
const dirPath = path.join(tmpDir, 'a', 'b', 'c')
|
|
await dispatcher.callRequest('fs.createDir', { dirPath })
|
|
|
|
const stats = await fs.stat(dirPath)
|
|
expect(stats.isDirectory()).toBe(true)
|
|
})
|
|
|
|
it('createDirNoClobber fails when the directory already exists', async () => {
|
|
const dirPath = path.join(tmpDir, 'existing')
|
|
mkdirSync(dirPath)
|
|
|
|
await expect(dispatcher.callRequest('fs.createDirNoClobber', { dirPath })).rejects.toThrow()
|
|
})
|
|
|
|
it('rename moves files', async () => {
|
|
const oldPath = path.join(tmpDir, 'old.txt')
|
|
const newPath = path.join(tmpDir, 'new.txt')
|
|
writeFileSync(oldPath, 'content')
|
|
|
|
await dispatcher.callRequest('fs.rename', { oldPath, newPath })
|
|
|
|
await expect(fs.access(oldPath)).rejects.toThrow()
|
|
const content = await fs.readFile(newPath, 'utf-8')
|
|
expect(content).toBe('content')
|
|
})
|
|
|
|
it('rename preserves raw fs.rename overwrite semantics', async () => {
|
|
const oldPath = path.join(tmpDir, 'old.txt')
|
|
const newPath = path.join(tmpDir, 'existing.txt')
|
|
writeFileSync(oldPath, 'new')
|
|
writeFileSync(newPath, 'keep')
|
|
|
|
await dispatcher.callRequest('fs.rename', { oldPath, newPath })
|
|
|
|
expect(await fs.readFile(newPath, 'utf-8')).toBe('new')
|
|
await expect(fs.access(oldPath)).rejects.toThrow()
|
|
})
|
|
|
|
it('renameNoClobber moves files when destination is available', async () => {
|
|
const oldPath = path.join(tmpDir, 'old.txt')
|
|
const newPath = path.join(tmpDir, 'new.txt')
|
|
writeFileSync(oldPath, 'content')
|
|
|
|
await dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath })
|
|
|
|
await expect(fs.access(oldPath)).rejects.toThrow()
|
|
expect(await fs.readFile(newPath, 'utf-8')).toBe('content')
|
|
})
|
|
|
|
it('renameNoClobber does not overwrite an existing destination', async () => {
|
|
const oldPath = path.join(tmpDir, 'old.txt')
|
|
const newPath = path.join(tmpDir, 'existing.txt')
|
|
writeFileSync(oldPath, 'new')
|
|
writeFileSync(newPath, 'keep')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.renameNoClobber', { oldPath, newPath })
|
|
).rejects.toThrow()
|
|
|
|
expect(await fs.readFile(newPath, 'utf-8')).toBe('keep')
|
|
expect(await fs.readFile(oldPath, 'utf-8')).toBe('new')
|
|
})
|
|
|
|
it('copy duplicates files', async () => {
|
|
const src = path.join(tmpDir, 'src.txt')
|
|
const dst = path.join(tmpDir, 'dst.txt')
|
|
writeFileSync(src, 'original')
|
|
|
|
await dispatcher.callRequest('fs.copy', { source: src, destination: dst })
|
|
|
|
const content = await fs.readFile(dst, 'utf-8')
|
|
expect(content).toBe('original')
|
|
})
|
|
|
|
it('copy does not overwrite an existing destination', async () => {
|
|
const src = path.join(tmpDir, 'src.txt')
|
|
const dst = path.join(tmpDir, 'dst.txt')
|
|
writeFileSync(src, 'original')
|
|
writeFileSync(dst, 'existing')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.copy', { source: src, destination: dst })
|
|
).rejects.toThrow('EEXIST')
|
|
|
|
const content = await fs.readFile(dst, 'utf-8')
|
|
expect(content).toBe('existing')
|
|
})
|
|
|
|
it('realpath resolves symlinks', async () => {
|
|
const realFile = path.join(tmpDir, 'real.txt')
|
|
const linkPath = path.join(tmpDir, 'link.txt')
|
|
writeFileSync(realFile, 'real')
|
|
symlinkSync(realFile, linkPath)
|
|
|
|
const result = (await dispatcher.callRequest('fs.realpath', { filePath: linkPath })) as string
|
|
// On macOS, /var is a symlink to /private/var, so resolve both to compare
|
|
const { realpathSync } = await import('fs')
|
|
expect(result).toBe(realpathSync(realFile))
|
|
})
|
|
|
|
it('does not let stale pending watch remove newer replacement watch', async () => {
|
|
const firstUnsubscribe = vi.fn()
|
|
const secondUnsubscribe = vi.fn()
|
|
let resolveFirst!: () => void
|
|
mockSubscribe
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveFirst = () => resolve({ unsubscribe: firstUnsubscribe })
|
|
})
|
|
)
|
|
.mockResolvedValueOnce({ unsubscribe: secondUnsubscribe })
|
|
|
|
const firstWatch = dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: tmpDir },
|
|
{ isStale: () => true }
|
|
)
|
|
while (mockSubscribe.mock.calls.length === 0) {
|
|
await Promise.resolve()
|
|
}
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false })
|
|
resolveFirst()
|
|
await firstWatch
|
|
|
|
expect(firstUnsubscribe).toHaveBeenCalled()
|
|
expect(secondUnsubscribe).not.toHaveBeenCalled()
|
|
dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir })
|
|
expect(secondUnsubscribe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('unsubscribes an active stale watch before replacing it', async () => {
|
|
const firstUnsubscribe = vi.fn()
|
|
const secondUnsubscribe = vi.fn()
|
|
mockSubscribe
|
|
.mockResolvedValueOnce({ unsubscribe: firstUnsubscribe })
|
|
.mockResolvedValueOnce({ unsubscribe: secondUnsubscribe })
|
|
|
|
let stale = false
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale })
|
|
stale = true
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false })
|
|
|
|
expect(firstUnsubscribe).toHaveBeenCalled()
|
|
expect(secondUnsubscribe).not.toHaveBeenCalled()
|
|
dispatcher.callNotification('fs.unwatch', { rootPath: tmpDir })
|
|
expect(secondUnsubscribe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('replaces a stale watch for the same root before enforcing the watch cap', async () => {
|
|
const firstUnsubscribe = vi.fn()
|
|
const replacementUnsubscribe = vi.fn()
|
|
mockSubscribe
|
|
.mockResolvedValueOnce({ unsubscribe: firstUnsubscribe })
|
|
.mockResolvedValueOnce({ unsubscribe: replacementUnsubscribe })
|
|
|
|
let stale = false
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => stale })
|
|
for (let index = 0; index < 19; index++) {
|
|
await dispatcher.callRequest('fs.watch', {
|
|
rootPath: path.join(tmpDir, `watched-${index}`)
|
|
})
|
|
}
|
|
|
|
stale = true
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false })
|
|
|
|
expect(firstUnsubscribe).toHaveBeenCalled()
|
|
expect(replacementUnsubscribe).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('removes stale watches for any root before enforcing the watch cap', async () => {
|
|
const staleUnsubscribe = vi.fn()
|
|
mockSubscribe
|
|
.mockResolvedValueOnce({ unsubscribe: staleUnsubscribe })
|
|
.mockResolvedValue({ unsubscribe: vi.fn() })
|
|
|
|
let stale = false
|
|
await dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: path.join(tmpDir, 'stale-root') },
|
|
{
|
|
isStale: () => stale
|
|
}
|
|
)
|
|
for (let index = 0; index < 19; index += 1) {
|
|
await dispatcher.callRequest('fs.watch', {
|
|
rootPath: path.join(tmpDir, `watched-${index}`)
|
|
})
|
|
}
|
|
|
|
stale = true
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.watch', { rootPath: path.join(tmpDir, 'new-root') })
|
|
).resolves.toBeUndefined()
|
|
expect(staleUnsubscribe).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps a shared watch alive until every client unwatches it', async () => {
|
|
const unsubscribe = vi.fn()
|
|
mockSubscribe.mockResolvedValue({ unsubscribe })
|
|
|
|
await dispatcher.callRequest('fs.watch', { rootPath: tmpDir }, { isStale: () => false })
|
|
await dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: tmpDir },
|
|
{
|
|
clientId: 2,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
|
|
dispatcher.callNotification(
|
|
'fs.unwatch',
|
|
{ rootPath: tmpDir },
|
|
{
|
|
clientId: 1,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
expect(unsubscribe).not.toHaveBeenCalled()
|
|
|
|
dispatcher.callNotification(
|
|
'fs.unwatch',
|
|
{ rootPath: tmpDir },
|
|
{
|
|
clientId: 2,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('allows a shared watch attach even when the root watch cap is full', async () => {
|
|
mockSubscribe.mockResolvedValue({ unsubscribe: vi.fn() })
|
|
|
|
for (let index = 0; index < 20; index += 1) {
|
|
const dir = path.join(tmpDir, `watched-${index}`)
|
|
await fs.mkdir(dir)
|
|
await dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: dir },
|
|
{
|
|
clientId: index + 1,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
}
|
|
|
|
await expect(
|
|
dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: path.join(tmpDir, 'watched-0') },
|
|
{
|
|
clientId: 99,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('releases a client watch when the dispatcher detaches that client', async () => {
|
|
const unsubscribe = vi.fn()
|
|
mockSubscribe.mockResolvedValue({ unsubscribe })
|
|
|
|
await dispatcher.callRequest(
|
|
'fs.watch',
|
|
{ rootPath: tmpDir },
|
|
{
|
|
clientId: 7,
|
|
isStale: () => false
|
|
}
|
|
)
|
|
dispatcher.detachClient(7)
|
|
|
|
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|