mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
110 files carried an eslint/oxlint-disable max-lines directive but are already under the default max-lines budget (300 .ts / 400 .tsx / 600 .mjs / 800 test), so the suppression is dead. Removing it restores real max-lines coverage on these files with zero behavior change. Each removed directive had max-lines as its only rule; verified via a full oxlint run (0 max-lines violations, 0 new errors). Diff is pure deletions (200 lines, 0 additions) — no code touched. Co-authored-by: Orca <help@stably.ai>
777 lines
26 KiB
TypeScript
777 lines
26 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { FsHandler } from './fs-handler'
|
|
import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils'
|
|
import { RelayContext } from './context'
|
|
import type { RelayDispatcher } from './dispatcher'
|
|
import * as fs from 'node:fs/promises'
|
|
import * as path from 'node:path'
|
|
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from 'node:fs'
|
|
import { tmpdir } from 'node: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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function statIdentity(stats: {
|
|
dev?: number
|
|
ino?: number
|
|
nlink?: number
|
|
size?: number
|
|
mtimeMs?: number
|
|
}) {
|
|
return `${stats.dev}:${stats.ino}:${stats.nlink ?? 'unknown'}:${stats.size}:${stats.mtimeMs}`
|
|
}
|
|
|
|
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('readTerminalArtifact reads through a verified artifact handle', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-read.json')
|
|
writeFileSync(filePath, '{"ok":true}')
|
|
const stats = await fs.stat(filePath)
|
|
|
|
const result = (await dispatcher.callRequest('fs.readTerminalArtifact', {
|
|
filePath,
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})) as { content: string; isBinary: boolean }
|
|
|
|
expect(result).toEqual({ content: '{"ok":true}', isBinary: false })
|
|
})
|
|
|
|
it('readTerminalArtifact treats SVG artifacts as editable text', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact.svg')
|
|
writeFileSync(filePath, '<svg><text>ok</text></svg>')
|
|
const stats = await fs.stat(filePath)
|
|
|
|
const result = (await dispatcher.callRequest('fs.readTerminalArtifact', {
|
|
filePath,
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})) as { content: string; isBinary: boolean; isImage?: boolean }
|
|
|
|
expect(result).toEqual({ content: '<svg><text>ok</text></svg>', isBinary: false })
|
|
})
|
|
|
|
it('readTerminalArtifact rejects content beyond the requested byte limit', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-read-too-large.txt')
|
|
writeFileSync(filePath, 'abcdef')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.readTerminalArtifact', {
|
|
filePath,
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
maxBytes: 5
|
|
})
|
|
).rejects.toThrow('file_too_large')
|
|
})
|
|
|
|
it('writeTerminalArtifact writes through a verified artifact handle', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-write.json')
|
|
writeFileSync(filePath, '{"ok":true}')
|
|
const stats = await fs.stat(filePath)
|
|
|
|
const result = (await dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: '{"ok":false}',
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})) as { stat: { type: string; size: number } }
|
|
|
|
await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('{"ok":false}')
|
|
expect(result.stat).toMatchObject({ type: 'file', size: 12 })
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'writeTerminalArtifact preserves executable mode across the atomic rename',
|
|
async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-executable.sh')
|
|
writeFileSync(filePath, '#!/bin/sh\necho ok\n')
|
|
await fs.chmod(filePath, 0o755)
|
|
const stats = await fs.stat(filePath)
|
|
|
|
await dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: '#!/bin/sh\necho changed\n',
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})
|
|
|
|
expect((await fs.stat(filePath)).mode & 0o777).toBe(0o755)
|
|
}
|
|
)
|
|
|
|
it('writeTerminalArtifact rejects oversized existing content before writing', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-write-too-large.txt')
|
|
writeFileSync(filePath, 'abcdef')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: 'ok',
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
maxBytes: 5
|
|
})
|
|
).rejects.toThrow('file_too_large')
|
|
await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef')
|
|
})
|
|
|
|
it('writeTerminalArtifact clamps client-supplied maxBytes to the text-file cap', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-write-clamp.txt')
|
|
writeFileSync(filePath, 'abcdef')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: 'a'.repeat(MAX_TEXT_FILE_SIZE + 1),
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
maxBytes: Number.MAX_SAFE_INTEGER
|
|
})
|
|
).rejects.toThrow('file_too_large')
|
|
await expect(fs.readFile(filePath, 'utf-8')).resolves.toBe('abcdef')
|
|
})
|
|
|
|
it('writeTerminalArtifact rejects a retargeted symlink before writing outside temp', async () => {
|
|
const filePath = path.join(tmpDir, 'artifact-link.json')
|
|
const outsidePath = path.join(tmpDir, 'outside.json')
|
|
writeFileSync(filePath, '{"ok":true}')
|
|
writeFileSync(outsidePath, '{"secret":true}')
|
|
const stats = await fs.stat(filePath)
|
|
const expectedRealPath = await fs.realpath(filePath)
|
|
await fs.rm(filePath)
|
|
symlinkSync(outsidePath, filePath)
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: '{"ok":false}',
|
|
expectedRealPath,
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})
|
|
).rejects.toThrow('terminal_file_grant_stale')
|
|
await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}')
|
|
})
|
|
|
|
it('writeTerminalArtifact rejects hard-linked files before writing', async () => {
|
|
const outsidePath = path.join(tmpDir, 'outside-hardlink.json')
|
|
const filePath = path.join(tmpDir, 'artifact-hardlink.json')
|
|
writeFileSync(outsidePath, '{"secret":true}')
|
|
await fs.link(outsidePath, filePath)
|
|
const stats = await fs.stat(filePath)
|
|
|
|
await expect(
|
|
dispatcher.callRequest('fs.writeTerminalArtifact', {
|
|
filePath,
|
|
content: '{"ok":false}',
|
|
expectedRealPath: await fs.realpath(filePath),
|
|
expectedStatIdentity: statIdentity(stats),
|
|
maxBytes: 512 * 1024
|
|
})
|
|
).rejects.toThrow('terminal_file_grant_stale')
|
|
await expect(fs.readFile(outsidePath, 'utf-8')).resolves.toBe('{"secret":true}')
|
|
})
|
|
|
|
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
|
|
expect(result).toBe(await fs.realpath(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)
|
|
})
|
|
})
|