Download packages/core/src/commands/memory.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 65.3 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/commands/memory.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/commands/memory.test.ts
-
curl -L -o memory.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/commands/memory.test.ts
65.3 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import * as fs from 'node:fs/promises'; | |
| import * as os from 'node:os'; | |
| import * as path from 'node:path'; | |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import type { Config } from '../config/config.js'; | |
| import { Storage } from '../config/storage.js'; | |
| import { | |
| applyInboxMemoryPatch, | |
| dismissInboxSkill, | |
| dismissInboxMemoryPatch, | |
| listInboxSkills, | |
| listInboxPatches, | |
| listInboxMemoryPatches, | |
| applyInboxPatch, | |
| dismissInboxPatch, | |
| listMemoryFiles, | |
| moveInboxSkill, | |
| refreshMemory, | |
| showMemory, | |
| } from './memory.js'; | |
| vi.mock('../config/storage.js', () => ({ | |
| Storage: { | |
| getUserSkillsDir: vi.fn(), | |
| getGlobalGeminiDir: vi.fn(), | |
| }, | |
| })); | |
| describe('memory commands', () => { | |
| let mockConfig: Config; | |
| let mockMemoryContextRefresh: ReturnType<typeof vi.fn>; | |
| beforeEach(() => { | |
| mockMemoryContextRefresh = vi.fn().mockResolvedValue(undefined); | |
| mockConfig = { | |
| getUserMemory: vi.fn(), | |
| getGeminiMdFileCount: vi.fn(), | |
| getGeminiMdFilePaths: vi.fn(), | |
| getMemoryContextManager: vi.fn().mockReturnValue({ | |
| refresh: mockMemoryContextRefresh, | |
| }), | |
| updateSystemInstructionIfInitialized: vi | |
| .fn() | |
| .mockResolvedValue(undefined), | |
| } as unknown as Config; | |
| }); | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| describe('showMemory', () => { | |
| it('should show memory content if it exists', () => { | |
| vi.mocked(mockConfig.getUserMemory).mockReturnValue( | |
| 'some memory content', | |
| ); | |
| vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(1); | |
| const result = showMemory(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toContain( | |
| 'Current memory content from 1 file(s)', | |
| ); | |
| expect(result.content).toContain('some memory content'); | |
| } | |
| }); | |
| it('should show a message if memory is empty', () => { | |
| vi.mocked(mockConfig.getUserMemory).mockReturnValue(''); | |
| vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0); | |
| const result = showMemory(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toBe('Memory is currently empty.'); | |
| } | |
| }); | |
| }); | |
| describe('refreshMemory', () => { | |
| it('should refresh memory and show success message', async () => { | |
| vi.mocked(mockConfig.getUserMemory).mockReturnValue({ | |
| project: 'refreshed content', | |
| }); | |
| vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(2); | |
| const result = await refreshMemory(mockConfig); | |
| expect(mockMemoryContextRefresh).toHaveBeenCalled(); | |
| expect( | |
| mockConfig.updateSystemInstructionIfInitialized, | |
| ).toHaveBeenCalled(); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toBe( | |
| 'Memory reloaded successfully. Loaded 33 characters from 2 file(s)', | |
| ); | |
| } | |
| }); | |
| it('should show a message if no memory content is found after refresh', async () => { | |
| vi.mocked(mockConfig.getUserMemory).mockReturnValue({ project: '' }); | |
| vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0); | |
| const result = await refreshMemory(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toBe( | |
| 'Memory reloaded successfully. No memory content found', | |
| ); | |
| } | |
| }); | |
| }); | |
| describe('listMemoryFiles', () => { | |
| it('should list the memory files in use', () => { | |
| const filePaths = ['/path/to/GEMINI.md', '/other/path/GEMINI.md']; | |
| vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue(filePaths); | |
| const result = listMemoryFiles(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toContain( | |
| 'There are 2 GEMINI.md file(s) in use:', | |
| ); | |
| expect(result.content).toContain(filePaths.join('\n')); | |
| } | |
| }); | |
| it('should show a message if no memory files are in use', () => { | |
| vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue([]); | |
| const result = listMemoryFiles(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toBe('No GEMINI.md files in use.'); | |
| } | |
| }); | |
| it('should show a message if file paths are undefined', () => { | |
| vi.mocked(mockConfig.getGeminiMdFilePaths).mockReturnValue( | |
| undefined as unknown as string[], | |
| ); | |
| const result = listMemoryFiles(mockConfig); | |
| expect(result.type).toBe('message'); | |
| if (result.type === 'message') { | |
| expect(result.messageType).toBe('info'); | |
| expect(result.content).toBe('No GEMINI.md files in use.'); | |
| } | |
| }); | |
| }); | |
| describe('listInboxSkills', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let memoryTempDir: string; | |
| let inboxConfig: Config; | |
| async function writeSkillMd( | |
| dirName: string, | |
| name: string, | |
| description: string, | |
| ): Promise<void> { | |
| const dir = path.join(skillsDir, dirName); | |
| await fs.mkdir(dir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(dir, 'SKILL.md'), | |
| `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`, | |
| ); | |
| } | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| memoryTempDir = path.join(tmpDir, 'memory-temp'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| await fs.mkdir(memoryTempDir, { recursive: true }); | |
| inboxConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| getProjectSkillsDir: () => path.join(tmpDir, 'project-skills'), | |
| }, | |
| } as unknown as Config; | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should return inbox skills with name, description, and extractedAt', async () => { | |
| await writeSkillMd('my-skill', 'my-skill', 'A test skill'); | |
| await writeSkillMd('other-skill', 'other-skill', 'Another skill'); | |
| const stateContent = JSON.stringify({ | |
| runs: [ | |
| { | |
| runAt: '2025-01-15T10:00:00Z', | |
| sessionIds: ['sess-1'], | |
| skillsCreated: ['my-skill'], | |
| }, | |
| { | |
| runAt: '2025-01-16T12:00:00Z', | |
| sessionIds: ['sess-2'], | |
| skillsCreated: ['other-skill'], | |
| }, | |
| ], | |
| }); | |
| await fs.writeFile( | |
| path.join(memoryTempDir, '.extraction-state.json'), | |
| stateContent, | |
| ); | |
| const skills = await listInboxSkills(inboxConfig); | |
| expect(skills).toHaveLength(2); | |
| const mySkill = skills.find((s) => s.dirName === 'my-skill'); | |
| expect(mySkill).toBeDefined(); | |
| expect(mySkill!.name).toBe('my-skill'); | |
| expect(mySkill!.description).toBe('A test skill'); | |
| expect(mySkill!.extractedAt).toBe('2025-01-15T10:00:00Z'); | |
| const otherSkill = skills.find((s) => s.dirName === 'other-skill'); | |
| expect(otherSkill).toBeDefined(); | |
| expect(otherSkill!.name).toBe('other-skill'); | |
| expect(otherSkill!.description).toBe('Another skill'); | |
| expect(otherSkill!.extractedAt).toBe('2025-01-16T12:00:00Z'); | |
| }); | |
| it('should return an empty array when the inbox is empty', async () => { | |
| const skills = await listInboxSkills(inboxConfig); | |
| expect(skills).toEqual([]); | |
| }); | |
| it('should return an empty array when the inbox directory does not exist', async () => { | |
| const missingConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'), | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| }, | |
| } as unknown as Config; | |
| const skills = await listInboxSkills(missingConfig); | |
| expect(skills).toEqual([]); | |
| }); | |
| }); | |
| describe('memory patch inbox', () => { | |
| let tmpDir: string; | |
| let memoryTempDir: string; | |
| let projectRoot: string; | |
| let globalMemoryDir: string; | |
| let patchConfig: Config; | |
| const isCaseInsensitivePathPlatform = | |
| process.platform === 'win32' || process.platform === 'darwin'; | |
| function buildUpdatePatch( | |
| absoluteTargetPath: string, | |
| original: string, | |
| updated: string, | |
| ): string { | |
| // Minimal one-hunk patch that replaces `original` with `updated`. | |
| const oldLines = original === '' ? 0 : original.split('\n').length - 1; | |
| const newLines = updated === '' ? 0 : updated.split('\n').length - 1; | |
| const removed = original | |
| .split('\n') | |
| .slice(0, oldLines) | |
| .map((line) => `-${line}`); | |
| const added = updated | |
| .split('\n') | |
| .slice(0, newLines) | |
| .map((line) => `+${line}`); | |
| return [ | |
| `--- ${absoluteTargetPath}`, | |
| `+++ ${absoluteTargetPath}`, | |
| `@@ -1,${oldLines} +1,${newLines} @@`, | |
| ...removed, | |
| ...added, | |
| '', | |
| ].join('\n'); | |
| } | |
| function buildCreationPatch( | |
| absoluteTargetPath: string, | |
| content: string, | |
| ): string { | |
| const contentLines = content.split('\n'); | |
| const lineCount = content.endsWith('\n') | |
| ? contentLines.length - 1 | |
| : contentLines.length; | |
| const additions = ( | |
| content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines | |
| ).map((line) => `+${line}`); | |
| return [ | |
| `--- /dev/null`, | |
| `+++ ${absoluteTargetPath}`, | |
| `@@ -0,0 +1,${lineCount} @@`, | |
| ...additions, | |
| '', | |
| ].join('\n'); | |
| } | |
| function swapAsciiPathCase(filePath: string): string { | |
| return filePath.replace(/[a-z]/gi, (char) => | |
| char === char.toLowerCase() ? char.toUpperCase() : char.toLowerCase(), | |
| ); | |
| } | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-')); | |
| // Canonicalize so test-side paths match production's | |
| // canonicalizeDirIfPresent → fs.realpath. On Windows runners | |
| // os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but | |
| // fs.realpath expands it to the long form (C:\Users\runneradmin\...), | |
| // which would otherwise break the auto-pointer absolute-path asserts. | |
| tmpDir = await fs.realpath(tmpDir); | |
| memoryTempDir = path.join(tmpDir, 'memory-temp'); | |
| projectRoot = path.join(tmpDir, 'project'); | |
| globalMemoryDir = path.join(tmpDir, 'global'); | |
| await fs.mkdir(memoryTempDir, { recursive: true }); | |
| await fs.mkdir(projectRoot, { recursive: true }); | |
| await fs.mkdir(globalMemoryDir, { recursive: true }); | |
| patchConfig = { | |
| storage: { | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| getProjectMemoryDir: () => memoryTempDir, | |
| }, | |
| isTrustedFolder: () => true, | |
| } as unknown as Config; | |
| vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('aggregates all .patch files of a kind into a single inbox entry', async () => { | |
| // Multiple physical .patch files in the kind dir → ONE consolidated | |
| // inbox entry per kind, with all hunks merged into entries[]. | |
| const target = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(target, '- old\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'a-update.patch'), | |
| buildUpdatePatch(target, '- old\n', '- new\n'), | |
| ); | |
| // Second source patch — same kind, different hunk. | |
| const sibling = path.join(memoryTempDir, 'topic.md'); | |
| await fs.writeFile(sibling, 'topic A\n'); | |
| await fs.writeFile( | |
| path.join(patchDir, 'b-topic.patch'), | |
| buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'), | |
| ); | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(1); | |
| const memoryPatch = patches[0]; | |
| expect(memoryPatch).toMatchObject({ | |
| kind: 'private', | |
| relativePath: 'private', | |
| name: 'Private memory', | |
| }); | |
| // Both source files contributed their hunks. | |
| expect(memoryPatch.entries).toHaveLength(2); | |
| expect(memoryPatch.sourceFiles).toEqual([ | |
| 'a-update.patch', | |
| 'b-topic.patch', | |
| ]); | |
| expect(memoryPatch.entries[0].targetPath).toBe(target); | |
| expect(memoryPatch.entries[0].isNewFile).toBe(false); | |
| expect(memoryPatch.entries[1].targetPath).toBe(sibling); | |
| expect(memoryPatch.extractedAt).toBeDefined(); | |
| }); | |
| it('omits patches whose headers leave the allowed root from the listing', async () => { | |
| // Bad patches must NOT show up in the inbox at all — listing filters | |
| // them out so the user only ever sees actionable items. (They'd also | |
| // be rejected at Apply time, but we don't want to surface them.) | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'escape.patch'), | |
| buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'), | |
| ); | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(0); | |
| // Direct apply still rejects it (defense-in-depth). | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'escape.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toMatch(/outside the private memory root/i); | |
| }); | |
| it('rejects private patches that target in-root non-memory documents', async () => { | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| const rejectedTargets = [ | |
| ['state.patch', path.join(memoryTempDir, '.extraction-state.json')], | |
| ['lock.patch', path.join(memoryTempDir, '.extraction.lock')], | |
| [ | |
| 'inbox.patch', | |
| path.join(memoryTempDir, '.inbox', 'private', 'review.md'), | |
| ], | |
| [ | |
| 'skills.patch', | |
| path.join(memoryTempDir, 'skills', 'generated', 'SKILL.md'), | |
| ], | |
| ['text.patch', path.join(memoryTempDir, 'notes.txt')], | |
| ['nested.patch', path.join(memoryTempDir, 'nested', 'topic.md')], | |
| ] as const; | |
| for (const [fileName, targetPath] of rejectedTargets) { | |
| await fs.writeFile( | |
| path.join(patchDir, fileName), | |
| buildCreationPatch(targetPath, 'rejected\n'), | |
| ); | |
| } | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(0); | |
| for (const [fileName, targetPath] of rejectedTargets) { | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| fileName, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toMatch( | |
| /outside the private memory root or target allowlist/i, | |
| ); | |
| await expect(fs.access(targetPath)).rejects.toThrow(); | |
| } | |
| }); | |
| it('omits global patches with disallowed targets from the listing', async () => { | |
| // Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed. | |
| // memory.md (legacy lowercase), sibling .md files, and settings.json all | |
| // get filtered out of the listing instead of confusing the user. | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'wrong-name.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'memory.md'), | |
| 'rejected\n', | |
| ), | |
| ); | |
| await fs.writeFile( | |
| path.join(patchDir, 'sibling.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'notes.md'), | |
| 'rejected\n', | |
| ), | |
| ); | |
| await fs.writeFile( | |
| path.join(patchDir, 'settings.patch'), | |
| buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'), | |
| ); | |
| await fs.writeFile( | |
| path.join(patchDir, 'nested.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'GEMINI.md', 'nested.md'), | |
| 'rejected\n', | |
| ), | |
| ); | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(0); | |
| }); | |
| it('applies a private update patch and removes it from the inbox', async () => { | |
| const target = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(target, '- old\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'MEMORY.patch'), | |
| buildUpdatePatch(target, '- old\n', '- accepted\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'MEMORY.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n'); | |
| await expect( | |
| fs.access(path.join(patchDir, 'MEMORY.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it.runIf(isCaseInsensitivePathPlatform)( | |
| 'accepts private memory patch targets with different path casing', | |
| async () => { | |
| const target = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(target, '- old\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'MEMORY.patch'), | |
| buildUpdatePatch( | |
| swapAsciiPathCase(target), | |
| '- old\n', | |
| '- accepted\n', | |
| ), | |
| ); | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(1); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'MEMORY.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '- accepted\n', | |
| ); | |
| }, | |
| ); | |
| it('applies a private creation patch with a paired MEMORY.md pointer', async () => { | |
| // The auto-memory contract: creating a sibling .md file requires a | |
| // hunk that adds a pointer to MEMORY.md (so the sibling becomes | |
| // discoverable to future sessions). | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(memoryMd, '# Project Memory\n'); | |
| const target = path.join(memoryTempDir, 'topic.md'); | |
| await expect(fs.access(target)).rejects.toThrow(); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| const multiHunkPatch = | |
| buildCreationPatch(target, '# Topic\n- new fact\n') + | |
| buildUpdatePatch( | |
| memoryMd, | |
| '# Project Memory\n', | |
| '# Project Memory\n- See topic.md for the new fact.\n', | |
| ); | |
| await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'topic.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '# Topic\n- new fact\n', | |
| ); | |
| await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain( | |
| 'See topic.md', | |
| ); | |
| await expect( | |
| fs.access(path.join(patchDir, 'topic.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => { | |
| // Sibling .md files in <memoryDir> are loaded by future sessions ONLY | |
| // when MEMORY.md references them. To avoid orphans, applying a sibling | |
| // creation patch with no MEMORY.md update auto-bundles a pointer line. | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(memoryMd, '# Project Memory\n'); | |
| const target = path.join(memoryTempDir, 'orphan-topic.md'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'orphan-topic.patch'), | |
| buildCreationPatch(target, '# Orphan Topic\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'orphan-topic.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i); | |
| expect(result.message).toContain('"orphan-topic.md"'); | |
| // The sibling exists. | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '# Orphan Topic\n', | |
| ); | |
| // MEMORY.md now references the sibling — using ABSOLUTE PATH so a | |
| // future agent can `read_file` it without resolving relatives. We | |
| // assert the line shape is `- See <absolute>/orphan-topic.md ...` and | |
| // verify the path is absolute via path.isAbsolute (cross-platform — | |
| // the previous /^- See \/.+\/.../ regex was Unix-only and broke on | |
| // Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`). | |
| const memoryAfter = await fs.readFile(memoryMd, 'utf-8'); | |
| expect(memoryAfter).toContain(target); | |
| const pointerLineMatch = memoryAfter.match( | |
| /^- See (.+orphan-topic\.md) /m, | |
| ); | |
| expect(pointerLineMatch).not.toBeNull(); | |
| expect(path.isAbsolute(pointerLineMatch![1])).toBe(true); | |
| // The patch was committed and removed from inbox. | |
| await expect( | |
| fs.access(path.join(patchDir, 'orphan-topic.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => { | |
| // No MEMORY.md on disk + a creation patch for a sibling → | |
| // auto-bundle should create MEMORY.md from scratch with the pointer. | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await expect(fs.access(memoryMd)).rejects.toThrow(); | |
| const target = path.join(memoryTempDir, 'fresh-topic.md'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'fresh-topic.patch'), | |
| buildCreationPatch(target, '# Fresh Topic\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'fresh-topic.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i); | |
| const memoryAfter = await fs.readFile(memoryMd, 'utf-8'); | |
| expect(memoryAfter).toContain('Project Memory'); | |
| // Pointer must be absolute so the future agent can read_file directly. | |
| expect(memoryAfter).toContain(target); | |
| }); | |
| it('accepts a private creation patch when MEMORY.md already references the new file', async () => { | |
| // If MEMORY.md was previously prepared with a pointer (e.g. by a | |
| // separately-applied patch), the follow-up creation patch is fine. | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile( | |
| memoryMd, | |
| '# Project Memory\n- See later-topic.md for details.\n', | |
| ); | |
| const target = path.join(memoryTempDir, 'later-topic.md'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'later-topic.patch'), | |
| buildCreationPatch(target, '# Later Topic\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'later-topic.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '# Later Topic\n', | |
| ); | |
| }); | |
| it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => { | |
| const target = path.join(globalMemoryDir, 'GEMINI.md'); | |
| // Sanity check: target does not exist before apply. | |
| await expect(fs.access(target)).rejects.toThrow(); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'GEMINI.patch'), | |
| buildCreationPatch(target, '# Personal preferences\n- prefer X\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'global', | |
| 'GEMINI.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '# Personal preferences\n- prefer X\n', | |
| ); | |
| await expect( | |
| fs.access(path.join(patchDir, 'GEMINI.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('applies a global update patch to ~/.gemini/GEMINI.md', async () => { | |
| const target = path.join(globalMemoryDir, 'GEMINI.md'); | |
| await fs.writeFile(target, '- prefer X\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'GEMINI.patch'), | |
| buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'global', | |
| 'GEMINI.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n'); | |
| await expect( | |
| fs.access(path.join(patchDir, 'GEMINI.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it.runIf(isCaseInsensitivePathPlatform)( | |
| 'accepts global memory patch targets with different path casing', | |
| async () => { | |
| const target = path.join(globalMemoryDir, 'GEMINI.md'); | |
| await fs.writeFile(target, '- prefer X\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'GEMINI.patch'), | |
| buildUpdatePatch( | |
| swapAsciiPathCase(target), | |
| '- prefer X\n', | |
| '- prefer Y\n', | |
| ), | |
| ); | |
| const patches = await listInboxMemoryPatches(patchConfig); | |
| expect(patches).toHaveLength(1); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'global', | |
| 'GEMINI.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| '- prefer Y\n', | |
| ); | |
| }, | |
| ); | |
| it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => { | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'GEMINI.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'GEMINI.md'), | |
| 'Prefer concise.\n', | |
| ), | |
| ); | |
| const result = await dismissInboxMemoryPatch( | |
| patchConfig, | |
| 'global', | |
| 'GEMINI.patch', | |
| ); | |
| expect(result.success).toBe(true); | |
| await expect( | |
| fs.access(path.join(patchDir, 'GEMINI.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('apply with relativePath = kind runs every source patch in sequence', async () => { | |
| // Aggregate apply: pass `relativePath = kind`. Each .patch file under | |
| // the kind dir is applied atomically in lexical order; the result | |
| // message summarizes successes/failures. | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(memoryMd, '- old\n'); | |
| const sibling = path.join(memoryTempDir, 'topic.md'); | |
| await fs.writeFile(sibling, 'topic A\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'a-update.patch'), | |
| buildUpdatePatch(memoryMd, '- old\n', '- new\n'), | |
| ); | |
| await fs.writeFile( | |
| path.join(patchDir, 'b-topic.patch'), | |
| buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'private', // ← aggregate mode | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toMatch(/applied all 2 private memory patches/i); | |
| // Both targets were updated, both source patches removed. | |
| await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n'); | |
| await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n'); | |
| await expect( | |
| fs.access(path.join(patchDir, 'a-update.patch')), | |
| ).rejects.toThrow(); | |
| await expect( | |
| fs.access(path.join(patchDir, 'b-topic.patch')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('aggregate apply reports successes and failures when one source patch is stale', async () => { | |
| const memoryMd = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(memoryMd, '- old\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| // Good patch: updates the existing line. | |
| await fs.writeFile( | |
| path.join(patchDir, 'a-good.patch'), | |
| buildUpdatePatch(memoryMd, '- old\n', '- new\n'), | |
| ); | |
| // Stale patch: context expects something that doesn't exist. | |
| await fs.writeFile( | |
| path.join(patchDir, 'b-stale.patch'), | |
| buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'private', | |
| ); | |
| // Any failure → success=false so the dialog keeps the inbox entry | |
| // visible. (The successful sub-patches were already removed from disk; | |
| // the next listing will surface only the failures for retry.) | |
| expect(result.success).toBe(false); | |
| expect(result.message).toMatch(/applied 1 of 2/i); | |
| expect(result.message).toMatch(/b-stale\.patch/); | |
| // Good patch committed and removed; stale patch stays in inbox. | |
| await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n'); | |
| await expect( | |
| fs.access(path.join(patchDir, 'a-good.patch')), | |
| ).rejects.toThrow(); | |
| await expect( | |
| fs.access(path.join(patchDir, 'b-stale.patch')), | |
| ).resolves.toBeUndefined(); | |
| }); | |
| it('dismiss with relativePath = kind removes all source patches', async () => { | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'a.patch'), | |
| buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'), | |
| ); | |
| await fs.writeFile( | |
| path.join(patchDir, 'b.patch'), | |
| buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'), | |
| ); | |
| const result = await dismissInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'private', | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toMatch(/dismissed 2/i); | |
| await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow(); | |
| await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow(); | |
| }); | |
| it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => { | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'global'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| // memory.md (lowercase) is NOT a valid global memory file. | |
| await fs.writeFile( | |
| path.join(patchDir, 'wrong-name.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'memory.md'), | |
| 'Should be rejected.\n', | |
| ), | |
| ); | |
| // Sibling .md files in ~/.gemini/ are also not allowed. | |
| await fs.writeFile( | |
| path.join(patchDir, 'sibling.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'notes.md'), | |
| 'Should be rejected.\n', | |
| ), | |
| ); | |
| // Non-memory files (settings, credentials) must stay off-limits. | |
| await fs.writeFile( | |
| path.join(patchDir, 'settings.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'settings.json'), | |
| '{"foo": 1}\n', | |
| ), | |
| ); | |
| // Child paths under the single allowed file path are not allowed either. | |
| await fs.writeFile( | |
| path.join(patchDir, 'nested.patch'), | |
| buildCreationPatch( | |
| path.join(globalMemoryDir, 'GEMINI.md', 'nested.md'), | |
| 'Should be rejected.\n', | |
| ), | |
| ); | |
| for (const fileName of [ | |
| 'wrong-name.patch', | |
| 'sibling.patch', | |
| 'settings.patch', | |
| 'nested.patch', | |
| ]) { | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'global', | |
| fileName, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toMatch(/outside the global memory root/i); | |
| } | |
| // None of the bogus targets were created. | |
| for (const orphan of ['memory.md', 'notes.md', 'settings.json']) { | |
| await expect( | |
| fs.access(path.join(globalMemoryDir, orphan)), | |
| ).rejects.toThrow(); | |
| } | |
| await expect( | |
| fs.access(path.join(globalMemoryDir, 'GEMINI.md', 'nested.md')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('rejects invalid memory patch paths', async () => { | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| '../MEMORY.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Invalid memory patch path.'); | |
| }); | |
| it('rejects a creation patch whose target already exists', async () => { | |
| const target = path.join(memoryTempDir, 'MEMORY.md'); | |
| await fs.writeFile(target, 'pre-existing\n'); | |
| const patchDir = path.join(memoryTempDir, '.inbox', 'private'); | |
| await fs.mkdir(patchDir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(patchDir, 'MEMORY.patch'), | |
| buildCreationPatch(target, 'replacement\n'), | |
| ); | |
| const result = await applyInboxMemoryPatch( | |
| patchConfig, | |
| 'private', | |
| 'MEMORY.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toMatch(/declares a new file/); | |
| await expect(fs.readFile(target, 'utf-8')).resolves.toBe( | |
| 'pre-existing\n', | |
| ); | |
| await expect( | |
| fs.access(path.join(patchDir, 'MEMORY.patch')), | |
| ).resolves.toBeUndefined(); | |
| }); | |
| }); | |
| describe('moveInboxSkill', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let globalSkillsDir: string; | |
| let projectSkillsDir: string; | |
| let moveConfig: Config; | |
| async function writeSkillMd( | |
| dirName: string, | |
| name: string, | |
| description: string, | |
| ): Promise<void> { | |
| const dir = path.join(skillsDir, dirName); | |
| await fs.mkdir(dir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(dir, 'SKILL.md'), | |
| `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`, | |
| ); | |
| } | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'move-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| globalSkillsDir = path.join(tmpDir, 'global-skills'); | |
| projectSkillsDir = path.join(tmpDir, 'project-skills'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| moveConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| getProjectSkillsDir: () => projectSkillsDir, | |
| }, | |
| } as unknown as Config; | |
| vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should move a skill to global skills directory', async () => { | |
| await writeSkillMd('my-skill', 'my-skill', 'A test skill'); | |
| const result = await moveInboxSkill(moveConfig, 'my-skill', 'global'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toBe('Moved "my-skill" to ~/.gemini/skills.'); | |
| // Verify the skill was copied to global | |
| const targetSkill = await fs.readFile( | |
| path.join(globalSkillsDir, 'my-skill', 'SKILL.md'), | |
| 'utf-8', | |
| ); | |
| expect(targetSkill).toContain('name: my-skill'); | |
| // Verify the skill was removed from inbox | |
| await expect( | |
| fs.access(path.join(skillsDir, 'my-skill')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('should move a skill to project skills directory', async () => { | |
| await writeSkillMd('my-skill', 'my-skill', 'A test skill'); | |
| const result = await moveInboxSkill(moveConfig, 'my-skill', 'project'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toBe('Moved "my-skill" to .gemini/skills.'); | |
| // Verify the skill was copied to project | |
| const targetSkill = await fs.readFile( | |
| path.join(projectSkillsDir, 'my-skill', 'SKILL.md'), | |
| 'utf-8', | |
| ); | |
| expect(targetSkill).toContain('name: my-skill'); | |
| // Verify the skill was removed from inbox | |
| await expect( | |
| fs.access(path.join(skillsDir, 'my-skill')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('should return an error when the source skill does not exist', async () => { | |
| const result = await moveInboxSkill(moveConfig, 'nonexistent', 'global'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Skill "nonexistent" not found in inbox.'); | |
| }); | |
| it('should reject invalid skill directory names', async () => { | |
| const result = await moveInboxSkill(moveConfig, '../escape', 'global'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Invalid skill name.'); | |
| }); | |
| it('should return an error when the target already exists', async () => { | |
| await writeSkillMd('my-skill', 'my-skill', 'A test skill'); | |
| // Pre-create the target | |
| const targetDir = path.join(globalSkillsDir, 'my-skill'); | |
| await fs.mkdir(targetDir, { recursive: true }); | |
| await fs.writeFile(path.join(targetDir, 'SKILL.md'), 'existing content'); | |
| const result = await moveInboxSkill(moveConfig, 'my-skill', 'global'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe( | |
| 'A skill named "my-skill" already exists in global skills.', | |
| ); | |
| }); | |
| it('should detect conflicts based on the normalized skill name', async () => { | |
| await writeSkillMd( | |
| 'inbox-skill', | |
| 'gke:prs-troubleshooter', | |
| 'A test skill', | |
| ); | |
| await fs.mkdir( | |
| path.join(globalSkillsDir, 'existing-gke-prs-troubleshooter'), | |
| { recursive: true }, | |
| ); | |
| await fs.writeFile( | |
| path.join( | |
| globalSkillsDir, | |
| 'existing-gke-prs-troubleshooter', | |
| 'SKILL.md', | |
| ), | |
| [ | |
| '---', | |
| 'name: gke-prs-troubleshooter', | |
| 'description: Existing skill', | |
| '---', | |
| 'Existing body content', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await moveInboxSkill(moveConfig, 'inbox-skill', 'global'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe( | |
| 'A skill named "gke-prs-troubleshooter" already exists in global skills.', | |
| ); | |
| await expect( | |
| fs.access(path.join(skillsDir, 'inbox-skill', 'SKILL.md')), | |
| ).resolves.toBeUndefined(); | |
| await expect( | |
| fs.access(path.join(globalSkillsDir, 'inbox-skill')), | |
| ).rejects.toThrow(); | |
| }); | |
| }); | |
| describe('dismissInboxSkill', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let dismissConfig: Config; | |
| async function writeSkillMd( | |
| dirName: string, | |
| name: string, | |
| description: string, | |
| ): Promise<void> { | |
| const dir = path.join(skillsDir, dirName); | |
| await fs.mkdir(dir, { recursive: true }); | |
| await fs.writeFile( | |
| path.join(dir, 'SKILL.md'), | |
| `---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`, | |
| ); | |
| } | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dismiss-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| dismissConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| }, | |
| } as unknown as Config; | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should remove a skill from the inbox', async () => { | |
| await writeSkillMd('my-skill', 'my-skill', 'A test skill'); | |
| const result = await dismissInboxSkill(dismissConfig, 'my-skill'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toBe('Dismissed "my-skill" from inbox.'); | |
| // Verify the skill directory was removed | |
| await expect( | |
| fs.access(path.join(skillsDir, 'my-skill')), | |
| ).rejects.toThrow(); | |
| }); | |
| it('should return an error when the skill does not exist', async () => { | |
| const result = await dismissInboxSkill(dismissConfig, 'nonexistent'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Skill "nonexistent" not found in inbox.'); | |
| }); | |
| it('should reject invalid skill directory names', async () => { | |
| const result = await dismissInboxSkill(dismissConfig, 'nested\\skill'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Invalid skill name.'); | |
| }); | |
| }); | |
| describe('listInboxPatches', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let memoryTempDir: string; | |
| let patchConfig: Config; | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-list-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| memoryTempDir = path.join(tmpDir, 'memory-temp'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| await fs.mkdir(memoryTempDir, { recursive: true }); | |
| patchConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| }, | |
| } as unknown as Config; | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should return empty array when no patches exist', async () => { | |
| const result = await listInboxPatches(patchConfig); | |
| expect(result).toEqual([]); | |
| }); | |
| it('should return empty array when directory does not exist', async () => { | |
| const badConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'), | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| }, | |
| } as unknown as Config; | |
| const result = await listInboxPatches(badConfig); | |
| expect(result).toEqual([]); | |
| }); | |
| it('should return parsed patch entries', async () => { | |
| const targetFile = path.join(tmpDir, 'target.md'); | |
| const patchContent = [ | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'); | |
| await fs.writeFile( | |
| path.join(skillsDir, 'update-skill.patch'), | |
| patchContent, | |
| ); | |
| const result = await listInboxPatches(patchConfig); | |
| expect(result).toHaveLength(1); | |
| expect(result[0].fileName).toBe('update-skill.patch'); | |
| expect(result[0].name).toBe('update-skill'); | |
| expect(result[0].entries).toHaveLength(1); | |
| expect(result[0].entries[0].targetPath).toBe(targetFile); | |
| expect(result[0].entries[0].diffContent).toContain('+line2.5'); | |
| }); | |
| it('should use each patch file mtime for extractedAt', async () => { | |
| const firstTarget = path.join(tmpDir, 'first.md'); | |
| const secondTarget = path.join(tmpDir, 'second.md'); | |
| const firstTimestamp = new Date('2025-01-15T10:00:00.000Z'); | |
| const secondTimestamp = new Date('2025-01-16T12:00:00.000Z'); | |
| await fs.writeFile( | |
| path.join(memoryTempDir, '.extraction-state.json'), | |
| JSON.stringify({ | |
| runs: [ | |
| { | |
| runAt: '2025-02-01T00:00:00Z', | |
| sessionIds: ['later-run'], | |
| skillsCreated: [], | |
| }, | |
| ], | |
| }), | |
| ); | |
| await fs.writeFile( | |
| path.join(skillsDir, 'first.patch'), | |
| [ | |
| `--- ${firstTarget}`, | |
| `+++ ${firstTarget}`, | |
| '@@ -1,1 +1,1 @@', | |
| '-before', | |
| '+after', | |
| '', | |
| ].join('\n'), | |
| ); | |
| await fs.writeFile( | |
| path.join(skillsDir, 'second.patch'), | |
| [ | |
| `--- ${secondTarget}`, | |
| `+++ ${secondTarget}`, | |
| '@@ -1,1 +1,1 @@', | |
| '-before', | |
| '+after', | |
| '', | |
| ].join('\n'), | |
| ); | |
| await fs.utimes( | |
| path.join(skillsDir, 'first.patch'), | |
| firstTimestamp, | |
| firstTimestamp, | |
| ); | |
| await fs.utimes( | |
| path.join(skillsDir, 'second.patch'), | |
| secondTimestamp, | |
| secondTimestamp, | |
| ); | |
| const result = await listInboxPatches(patchConfig); | |
| const firstPatch = result.find( | |
| (patch) => patch.fileName === 'first.patch', | |
| ); | |
| const secondPatch = result.find( | |
| (patch) => patch.fileName === 'second.patch', | |
| ); | |
| expect(firstPatch?.extractedAt).toBe(firstTimestamp.toISOString()); | |
| expect(secondPatch?.extractedAt).toBe(secondTimestamp.toISOString()); | |
| }); | |
| it('should skip patches with no hunks', async () => { | |
| await fs.writeFile( | |
| path.join(skillsDir, 'empty.patch'), | |
| 'not a valid patch', | |
| ); | |
| const result = await listInboxPatches(patchConfig); | |
| expect(result).toEqual([]); | |
| }); | |
| }); | |
| describe('applyInboxPatch', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let memoryTempDir: string; | |
| let globalSkillsDir: string; | |
| let projectSkillsDir: string; | |
| let applyConfig: Config; | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-apply-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| memoryTempDir = path.join(tmpDir, 'memory-temp'); | |
| globalSkillsDir = path.join(tmpDir, 'global-skills'); | |
| projectSkillsDir = path.join(tmpDir, 'project-skills'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| await fs.mkdir(memoryTempDir, { recursive: true }); | |
| await fs.mkdir(globalSkillsDir, { recursive: true }); | |
| await fs.mkdir(projectSkillsDir, { recursive: true }); | |
| applyConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| getProjectMemoryTempDir: () => memoryTempDir, | |
| getProjectSkillsDir: () => projectSkillsDir, | |
| }, | |
| isTrustedFolder: () => true, | |
| } as unknown as Config; | |
| vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should apply a valid patch and delete it', async () => { | |
| const targetFile = path.join(projectSkillsDir, 'target.md'); | |
| await fs.writeFile(targetFile, 'line1\nline2\nline3\n'); | |
| const patchContent = [ | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'); | |
| const patchPath = path.join(skillsDir, 'good.patch'); | |
| await fs.writeFile(patchPath, patchContent); | |
| const result = await applyInboxPatch(applyConfig, 'good.patch'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toContain('Applied patch to 1 file'); | |
| // Verify target was modified | |
| const modified = await fs.readFile(targetFile, 'utf-8'); | |
| expect(modified).toContain('line2.5'); | |
| // Verify patch was deleted | |
| await expect(fs.access(patchPath)).rejects.toThrow(); | |
| }); | |
| it('should apply a multi-file patch', async () => { | |
| const file1 = path.join(globalSkillsDir, 'file1.md'); | |
| const file2 = path.join(projectSkillsDir, 'file2.md'); | |
| await fs.writeFile(file1, 'aaa\nbbb\nccc\n'); | |
| await fs.writeFile(file2, 'xxx\nyyy\nzzz\n'); | |
| const patchContent = [ | |
| `--- ${file1}`, | |
| `+++ ${file1}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' aaa', | |
| ' bbb', | |
| '+bbb2', | |
| ' ccc', | |
| `--- ${file2}`, | |
| `+++ ${file2}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' xxx', | |
| ' yyy', | |
| '+yyy2', | |
| ' zzz', | |
| '', | |
| ].join('\n'); | |
| await fs.writeFile(path.join(skillsDir, 'multi.patch'), patchContent); | |
| const result = await applyInboxPatch(applyConfig, 'multi.patch'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toContain('2 files'); | |
| expect(await fs.readFile(file1, 'utf-8')).toContain('bbb2'); | |
| expect(await fs.readFile(file2, 'utf-8')).toContain('yyy2'); | |
| }); | |
| it('should apply repeated file blocks against the cumulative patched content', async () => { | |
| const targetFile = path.join(projectSkillsDir, 'target.md'); | |
| await fs.writeFile(targetFile, 'alpha\nbeta\ngamma\ndelta\n'); | |
| await fs.writeFile( | |
| path.join(skillsDir, 'multi-section.patch'), | |
| [ | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,4 +1,5 @@', | |
| ' alpha', | |
| ' beta', | |
| '+beta2', | |
| ' gamma', | |
| ' delta', | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -2,4 +2,5 @@', | |
| ' beta', | |
| ' beta2', | |
| ' gamma', | |
| '+gamma2', | |
| ' delta', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'multi-section.patch'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toContain('Applied patch to 1 file'); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe( | |
| 'alpha\nbeta\nbeta2\ngamma\ngamma2\ndelta\n', | |
| ); | |
| }); | |
| it('should reject /dev/null patches that target an existing skill file', async () => { | |
| const targetFile = path.join(projectSkillsDir, 'existing-skill.md'); | |
| await fs.writeFile(targetFile, 'original content\n'); | |
| const patchPath = path.join(skillsDir, 'bad-new-file.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| '--- /dev/null', | |
| `+++ ${targetFile}`, | |
| '@@ -0,0 +1 @@', | |
| '+replacement content', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'bad-new-file.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('target already exists'); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe('original content\n'); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should fail when patch does not exist', async () => { | |
| const result = await applyInboxPatch(applyConfig, 'missing.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('not found'); | |
| }); | |
| it('should reject invalid patch file names', async () => { | |
| const outsidePatch = path.join(tmpDir, 'outside.patch'); | |
| await fs.writeFile(outsidePatch, 'outside patch content'); | |
| const result = await applyInboxPatch(applyConfig, '../outside.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Invalid patch file name.'); | |
| await expect(fs.access(outsidePatch)).resolves.toBeUndefined(); | |
| }); | |
| it('should fail when target file does not exist', async () => { | |
| const missingFile = path.join(projectSkillsDir, 'missing-target.md'); | |
| const patchContent = [ | |
| `--- ${missingFile}`, | |
| `+++ ${missingFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' a', | |
| ' b', | |
| '+c', | |
| ' d', | |
| '', | |
| ].join('\n'); | |
| await fs.writeFile( | |
| path.join(skillsDir, 'bad-target.patch'), | |
| patchContent, | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'bad-target.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('Target file not found'); | |
| }); | |
| it('should reject targets outside the global and workspace skill roots', async () => { | |
| const outsideFile = path.join(tmpDir, 'outside.md'); | |
| await fs.writeFile(outsideFile, 'line1\nline2\nline3\n'); | |
| const patchContent = [ | |
| `--- ${outsideFile}`, | |
| `+++ ${outsideFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'); | |
| const patchPath = path.join(skillsDir, 'outside.patch'); | |
| await fs.writeFile(patchPath, patchContent); | |
| const result = await applyInboxPatch(applyConfig, 'outside.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain( | |
| 'outside the global/workspace skill directories', | |
| ); | |
| expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5'); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should reject targets that escape the skill root through a symlinked parent', async () => { | |
| const outsideDir = path.join(tmpDir, 'outside-dir'); | |
| const linkDir = path.join(projectSkillsDir, 'linked'); | |
| await fs.mkdir(outsideDir, { recursive: true }); | |
| await fs.symlink( | |
| outsideDir, | |
| linkDir, | |
| process.platform === 'win32' ? 'junction' : 'dir', | |
| ); | |
| const outsideFile = path.join(outsideDir, 'escaped.md'); | |
| await fs.writeFile(outsideFile, 'line1\nline2\nline3\n'); | |
| const patchPath = path.join(skillsDir, 'symlink.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- ${path.join(linkDir, 'escaped.md')}`, | |
| `+++ ${path.join(linkDir, 'escaped.md')}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'symlink.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain( | |
| 'outside the global/workspace skill directories', | |
| ); | |
| expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5'); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should reject patches that contain no hunks', async () => { | |
| await fs.writeFile( | |
| path.join(skillsDir, 'empty.patch'), | |
| [ | |
| `--- ${path.join(projectSkillsDir, 'target.md')}`, | |
| `+++ ${path.join(projectSkillsDir, 'target.md')}`, | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'empty.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('contains no valid hunks'); | |
| }); | |
| it('should reject project-scope patches when the workspace is untrusted', async () => { | |
| const targetFile = path.join(projectSkillsDir, 'target.md'); | |
| await fs.writeFile(targetFile, 'line1\nline2\nline3\n'); | |
| const patchPath = path.join(skillsDir, 'workspace.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const untrustedConfig = { | |
| storage: applyConfig.storage, | |
| isTrustedFolder: () => false, | |
| } as Config; | |
| const result = await applyInboxPatch(untrustedConfig, 'workspace.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain( | |
| 'Project skill patches are unavailable until this workspace is trusted.', | |
| ); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe( | |
| 'line1\nline2\nline3\n', | |
| ); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should reject project-scope patches through a symlinked project skills root when the workspace is untrusted', async () => { | |
| const realProjectSkillsDir = path.join(tmpDir, 'project-skills-real'); | |
| const symlinkedProjectSkillsDir = path.join( | |
| tmpDir, | |
| 'project-skills-link', | |
| ); | |
| await fs.mkdir(realProjectSkillsDir, { recursive: true }); | |
| await fs.symlink( | |
| realProjectSkillsDir, | |
| symlinkedProjectSkillsDir, | |
| process.platform === 'win32' ? 'junction' : 'dir', | |
| ); | |
| projectSkillsDir = symlinkedProjectSkillsDir; | |
| const targetFile = path.join(realProjectSkillsDir, 'target.md'); | |
| await fs.writeFile(targetFile, 'line1\nline2\nline3\n'); | |
| const patchPath = path.join(skillsDir, 'workspace-symlink.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- ${targetFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const untrustedConfig = { | |
| storage: applyConfig.storage, | |
| isTrustedFolder: () => false, | |
| } as Config; | |
| const result = await applyInboxPatch( | |
| untrustedConfig, | |
| 'workspace-symlink.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain( | |
| 'Project skill patches are unavailable until this workspace is trusted.', | |
| ); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe( | |
| 'line1\nline2\nline3\n', | |
| ); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should reject patches with mismatched diff headers', async () => { | |
| const sourceFile = path.join(projectSkillsDir, 'source.md'); | |
| const targetFile = path.join(projectSkillsDir, 'target.md'); | |
| await fs.writeFile(sourceFile, 'aaa\nbbb\nccc\n'); | |
| await fs.writeFile(targetFile, 'xxx\nyyy\nzzz\n'); | |
| const patchPath = path.join(skillsDir, 'mismatched-headers.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- ${sourceFile}`, | |
| `+++ ${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' xxx', | |
| ' yyy', | |
| '+yyy2', | |
| ' zzz', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch( | |
| applyConfig, | |
| 'mismatched-headers.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('invalid diff headers'); | |
| expect(await fs.readFile(sourceFile, 'utf-8')).toBe('aaa\nbbb\nccc\n'); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe('xxx\nyyy\nzzz\n'); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| it('should strip git-style a/ and b/ prefixes and apply successfully', async () => { | |
| const targetFile = path.join(projectSkillsDir, 'prefixed.md'); | |
| await fs.writeFile(targetFile, 'line1\nline2\nline3\n'); | |
| const patchPath = path.join(skillsDir, 'git-prefix.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- a/${targetFile}`, | |
| `+++ b/${targetFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' line1', | |
| ' line2', | |
| '+line2.5', | |
| ' line3', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'git-prefix.patch'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toContain('Applied patch to 1 file'); | |
| expect(await fs.readFile(targetFile, 'utf-8')).toBe( | |
| 'line1\nline2\nline2.5\nline3\n', | |
| ); | |
| await expect(fs.access(patchPath)).rejects.toThrow(); | |
| }); | |
| it('should not write any files if one patch in a multi-file set fails', async () => { | |
| const file1 = path.join(projectSkillsDir, 'file1.md'); | |
| await fs.writeFile(file1, 'aaa\nbbb\nccc\n'); | |
| const missingFile = path.join(projectSkillsDir, 'missing.md'); | |
| const patchContent = [ | |
| `--- ${file1}`, | |
| `+++ ${file1}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' aaa', | |
| ' bbb', | |
| '+bbb2', | |
| ' ccc', | |
| `--- ${missingFile}`, | |
| `+++ ${missingFile}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' x', | |
| ' y', | |
| '+z', | |
| ' w', | |
| '', | |
| ].join('\n'); | |
| await fs.writeFile(path.join(skillsDir, 'partial.patch'), patchContent); | |
| const result = await applyInboxPatch(applyConfig, 'partial.patch'); | |
| expect(result.success).toBe(false); | |
| // Verify file1 was NOT modified (dry-run failed) | |
| const content = await fs.readFile(file1, 'utf-8'); | |
| expect(content).not.toContain('bbb2'); | |
| }); | |
| it('should roll back earlier file updates if a later commit step fails', async () => { | |
| const file1 = path.join(projectSkillsDir, 'file1.md'); | |
| await fs.writeFile(file1, 'aaa\nbbb\nccc\n'); | |
| const conflictPath = path.join(projectSkillsDir, 'conflict'); | |
| const nestedNewFile = path.join(conflictPath, 'nested.md'); | |
| const patchPath = path.join(skillsDir, 'rollback.patch'); | |
| await fs.writeFile( | |
| patchPath, | |
| [ | |
| `--- ${file1}`, | |
| `+++ ${file1}`, | |
| '@@ -1,3 +1,4 @@', | |
| ' aaa', | |
| ' bbb', | |
| '+bbb2', | |
| ' ccc', | |
| '--- /dev/null', | |
| `+++ ${conflictPath}`, | |
| '@@ -0,0 +1 @@', | |
| '+new file content', | |
| '--- /dev/null', | |
| `+++ ${nestedNewFile}`, | |
| '@@ -0,0 +1 @@', | |
| '+nested new file content', | |
| '', | |
| ].join('\n'), | |
| ); | |
| const result = await applyInboxPatch(applyConfig, 'rollback.patch'); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('could not be applied atomically'); | |
| expect(await fs.readFile(file1, 'utf-8')).toBe('aaa\nbbb\nccc\n'); | |
| expect((await fs.stat(conflictPath)).isDirectory()).toBe(true); | |
| await expect(fs.access(nestedNewFile)).rejects.toThrow(); | |
| await expect(fs.access(patchPath)).resolves.toBeUndefined(); | |
| }); | |
| }); | |
| describe('dismissInboxPatch', () => { | |
| let tmpDir: string; | |
| let skillsDir: string; | |
| let dismissPatchConfig: Config; | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-dismiss-test-')); | |
| skillsDir = path.join(tmpDir, 'skills-memory'); | |
| await fs.mkdir(skillsDir, { recursive: true }); | |
| dismissPatchConfig = { | |
| storage: { | |
| getProjectSkillsMemoryDir: () => skillsDir, | |
| }, | |
| } as unknown as Config; | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| it('should delete the patch file and return success', async () => { | |
| const patchPath = path.join(skillsDir, 'old.patch'); | |
| await fs.writeFile(patchPath, 'some patch content'); | |
| const result = await dismissInboxPatch(dismissPatchConfig, 'old.patch'); | |
| expect(result.success).toBe(true); | |
| expect(result.message).toContain('Dismissed'); | |
| await expect(fs.access(patchPath)).rejects.toThrow(); | |
| }); | |
| it('should return error when patch does not exist', async () => { | |
| const result = await dismissInboxPatch( | |
| dismissPatchConfig, | |
| 'nonexistent.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toContain('not found'); | |
| }); | |
| it('should reject invalid patch file names', async () => { | |
| const outsidePatch = path.join(tmpDir, 'outside.patch'); | |
| await fs.writeFile(outsidePatch, 'outside patch content'); | |
| const result = await dismissInboxPatch( | |
| dismissPatchConfig, | |
| '../outside.patch', | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.message).toBe('Invalid patch file name.'); | |
| await expect(fs.access(outsidePatch)).resolves.toBeUndefined(); | |
| }); | |
| }); | |
| }); | |