2026-04-08 11:08:49 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2026 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { act } from 'react';
|
2026-04-13 10:44:52 -07:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
2026-05-04 12:07:13 -07:00
|
|
|
import type {
|
|
|
|
|
Config,
|
|
|
|
|
InboxSkill,
|
|
|
|
|
InboxPatch,
|
|
|
|
|
InboxMemoryPatch,
|
|
|
|
|
} from '@google/gemini-cli-core';
|
2026-04-08 11:08:49 -07:00
|
|
|
import {
|
|
|
|
|
dismissInboxSkill,
|
2026-05-04 12:07:13 -07:00
|
|
|
dismissInboxMemoryPatch,
|
2026-04-08 11:08:49 -07:00
|
|
|
listInboxSkills,
|
2026-04-13 10:44:52 -07:00
|
|
|
listInboxPatches,
|
2026-05-04 12:07:13 -07:00
|
|
|
listInboxMemoryPatches,
|
2026-04-08 11:08:49 -07:00
|
|
|
moveInboxSkill,
|
2026-04-13 10:44:52 -07:00
|
|
|
applyInboxPatch,
|
|
|
|
|
dismissInboxPatch,
|
2026-05-04 12:07:13 -07:00
|
|
|
applyInboxMemoryPatch,
|
2026-04-13 10:44:52 -07:00
|
|
|
isProjectSkillPatchTarget,
|
2026-04-08 11:08:49 -07:00
|
|
|
} from '@google/gemini-cli-core';
|
|
|
|
|
import { waitFor } from '../../test-utils/async.js';
|
|
|
|
|
import { renderWithProviders } from '../../test-utils/render.js';
|
2026-05-04 12:07:13 -07:00
|
|
|
import { InboxDialog } from './InboxDialog.js';
|
2026-04-08 11:08:49 -07:00
|
|
|
|
|
|
|
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|
|
|
|
const original =
|
|
|
|
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...original,
|
|
|
|
|
dismissInboxSkill: vi.fn(),
|
2026-05-04 12:07:13 -07:00
|
|
|
dismissInboxMemoryPatch: vi.fn(),
|
2026-04-08 11:08:49 -07:00
|
|
|
listInboxSkills: vi.fn(),
|
2026-04-13 10:44:52 -07:00
|
|
|
listInboxPatches: vi.fn(),
|
2026-05-04 12:07:13 -07:00
|
|
|
listInboxMemoryPatches: vi.fn(),
|
2026-04-08 11:08:49 -07:00
|
|
|
moveInboxSkill: vi.fn(),
|
2026-04-13 10:44:52 -07:00
|
|
|
applyInboxPatch: vi.fn(),
|
|
|
|
|
dismissInboxPatch: vi.fn(),
|
2026-05-04 12:07:13 -07:00
|
|
|
applyInboxMemoryPatch: vi.fn(),
|
2026-04-13 10:44:52 -07:00
|
|
|
isProjectSkillPatchTarget: vi.fn(),
|
2026-04-08 11:08:49 -07:00
|
|
|
getErrorMessage: vi.fn((error: unknown) =>
|
|
|
|
|
error instanceof Error ? error.message : String(error),
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
2026-04-13 10:44:52 -07:00
|
|
|
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
2026-05-04 12:07:13 -07:00
|
|
|
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
|
2026-04-08 11:08:49 -07:00
|
|
|
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
|
|
|
|
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
2026-04-13 10:44:52 -07:00
|
|
|
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
|
|
|
|
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
2026-05-04 12:07:13 -07:00
|
|
|
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
|
|
|
|
|
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
|
2026-04-13 10:44:52 -07:00
|
|
|
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
2026-04-08 11:08:49 -07:00
|
|
|
|
|
|
|
|
const inboxSkill: InboxSkill = {
|
|
|
|
|
dirName: 'inbox-skill',
|
|
|
|
|
name: 'Inbox Skill',
|
|
|
|
|
description: 'A test skill',
|
2026-04-13 10:44:52 -07:00
|
|
|
content:
|
|
|
|
|
'---\nname: Inbox Skill\ndescription: A test skill\n---\n\n## Procedure\n1. Do the thing\n',
|
2026-04-08 11:08:49 -07:00
|
|
|
extractedAt: '2025-01-15T10:00:00Z',
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
const inboxPatch: InboxPatch = {
|
|
|
|
|
fileName: 'update-docs.patch',
|
|
|
|
|
name: 'update-docs',
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'@@ -1,3 +1,4 @@',
|
|
|
|
|
' line1',
|
|
|
|
|
' line2',
|
|
|
|
|
'+line2.5',
|
|
|
|
|
' line3',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
extractedAt: '2025-01-20T14:00:00Z',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-04 12:07:13 -07:00
|
|
|
const inboxMemoryPatch: InboxMemoryPatch = {
|
|
|
|
|
kind: 'private',
|
|
|
|
|
relativePath: 'private',
|
|
|
|
|
name: 'Private memory',
|
|
|
|
|
sourceFiles: ['update-memory.patch'],
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
|
|
|
|
|
isNewFile: false,
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
|
|
|
|
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
|
|
|
|
|
'@@ -1,1 +1,1 @@',
|
|
|
|
|
'-old',
|
|
|
|
|
'+use focused tests',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
extractedAt: '2025-01-21T10:00:00Z',
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
const workspacePatch: InboxPatch = {
|
|
|
|
|
fileName: 'workspace-update.patch',
|
|
|
|
|
name: 'workspace-update',
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'@@ -1,1 +1,2 @@',
|
|
|
|
|
' line1',
|
|
|
|
|
'+line2',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const multiSectionPatch: InboxPatch = {
|
|
|
|
|
fileName: 'multi-section.patch',
|
|
|
|
|
name: 'multi-section',
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'@@ -1,1 +1,2 @@',
|
|
|
|
|
' line1',
|
|
|
|
|
'+line2',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
'@@ -3,1 +4,2 @@',
|
|
|
|
|
' line3',
|
|
|
|
|
'+line4',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const windowsGlobalPatch: InboxPatch = {
|
|
|
|
|
fileName: 'windows-update.patch',
|
|
|
|
|
name: 'windows-update',
|
|
|
|
|
entries: [
|
|
|
|
|
{
|
|
|
|
|
targetPath: 'C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
|
|
|
|
diffContent: [
|
|
|
|
|
'--- C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
|
|
|
|
'+++ C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
|
|
|
|
'@@ -1,1 +1,2 @@',
|
|
|
|
|
' line1',
|
|
|
|
|
'+line2',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-04 12:07:13 -07:00
|
|
|
describe('InboxDialog', () => {
|
2026-04-08 11:08:49 -07:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
2026-04-13 10:44:52 -07:00
|
|
|
mockListInboxPatches.mockResolvedValue([]);
|
2026-05-04 12:07:13 -07:00
|
|
|
mockListInboxMemoryPatches.mockResolvedValue([]);
|
2026-04-08 11:08:49 -07:00
|
|
|
mockMoveInboxSkill.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
|
|
|
|
});
|
|
|
|
|
mockDismissInboxSkill.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Dismissed "inbox-skill" from inbox.',
|
|
|
|
|
});
|
2026-04-13 10:44:52 -07:00
|
|
|
mockApplyInboxPatch.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Applied patch to 1 file.',
|
|
|
|
|
});
|
|
|
|
|
mockDismissInboxPatch.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Dismissed "update-docs.patch" from inbox.',
|
|
|
|
|
});
|
2026-05-04 12:07:13 -07:00
|
|
|
mockApplyInboxMemoryPatch.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Applied memory patch to 1 file.',
|
|
|
|
|
});
|
|
|
|
|
mockDismissInboxMemoryPatch.mockResolvedValue({
|
|
|
|
|
success: true,
|
|
|
|
|
message: 'Dismissed 1 private memory patch from inbox.',
|
|
|
|
|
});
|
2026-04-13 10:44:52 -07:00
|
|
|
mockIsProjectSkillPatchTarget.mockImplementation(
|
|
|
|
|
async (targetPath: string, config: Config) => {
|
|
|
|
|
const projectSkillsDir = config.storage
|
|
|
|
|
?.getProjectSkillsDir?.()
|
|
|
|
|
?.replaceAll('\\', '/')
|
|
|
|
|
?.replace(/\/+$/, '');
|
|
|
|
|
|
|
|
|
|
return projectSkillsDir
|
|
|
|
|
? targetPath.replaceAll('\\', '/').startsWith(projectSkillsDir)
|
|
|
|
|
: false;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.unstubAllEnvs();
|
2026-04-08 11:08:49 -07:00
|
|
|
});
|
|
|
|
|
|
2026-05-04 12:07:13 -07:00
|
|
|
it('reviews and applies memory patches', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<InboxDialog
|
|
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn()}
|
|
|
|
|
onReloadMemory={onReloadMemory}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('Private memory');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame() ?? '';
|
|
|
|
|
expect(frame).toContain('Review');
|
|
|
|
|
expect(frame).toMatch(/source patch/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Memory patches default to Dismiss as the highlighted action so a stray
|
|
|
|
|
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
|
|
|
|
|
// press Enter to confirm.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\u001B[B'); // arrow down → Apply
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
// Aggregate apply: relativePath equals the kind name.
|
|
|
|
|
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
|
|
|
|
|
config,
|
|
|
|
|
'private',
|
|
|
|
|
'private',
|
|
|
|
|
);
|
|
|
|
|
expect(onReloadMemory).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-08 11:08:49 -07:00
|
|
|
it('disables the project destination when the workspace is untrusted', async () => {
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(false),
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-08 11:08:49 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={onReloadSkills}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('Inbox Skill');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select skill → lands on preview
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-04-13 10:44:52 -07:00
|
|
|
expect(lastFrame()).toContain('Review new skill');
|
2026-04-08 11:08:49 -07:00
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select "Move" → lands on destination chooser
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
2026-04-13 10:44:52 -07:00
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Project');
|
|
|
|
|
expect(frame).toContain('unavailable until this workspace is trusted');
|
2026-04-08 11:08:49 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows inline feedback when moving a skill throws', async () => {
|
|
|
|
|
mockMoveInboxSkill.mockRejectedValue(new Error('permission denied'));
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-08 11:08:49 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('Inbox Skill');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select skill → preview
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select "Move" → destination chooser
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select "Global" → triggers move
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Move "Inbox Skill"');
|
|
|
|
|
expect(frame).toContain('Failed to install skill: permission denied');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows inline feedback when reloading skills fails after a move', async () => {
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const onReloadSkills = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockRejectedValue(new Error('reload hook failed'));
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-08 11:08:49 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={onReloadSkills}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('Inbox Skill');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select skill → preview
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
// Select "Move" → destination chooser
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select "Global" → triggers move
|
2026-04-08 11:08:49 -07:00
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain(
|
|
|
|
|
'Moved "inbox-skill" to ~/.gemini/skills. Failed to reload skills: reload hook failed',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
expect(onReloadSkills).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
2026-04-13 10:44:52 -07:00
|
|
|
|
2026-05-04 12:07:13 -07:00
|
|
|
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
|
|
|
|
|
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
|
|
|
|
|
// the list with focus jumped back to row 0 instead of staying on the row
|
|
|
|
|
// the user was on.
|
|
|
|
|
const secondSkill: InboxSkill = {
|
|
|
|
|
...inboxSkill,
|
|
|
|
|
dirName: 'second-skill',
|
|
|
|
|
name: 'Second Skill',
|
|
|
|
|
};
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<InboxDialog
|
|
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Inbox Skill');
|
|
|
|
|
expect(frame).toContain('Second Skill');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Arrow down to the second row.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\x1b[B');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Enter the second row's preview.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Review new skill');
|
|
|
|
|
expect(frame).toContain('Second Skill');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Esc back to list.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\x1b');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Inbox Skill');
|
|
|
|
|
expect(frame).toContain('Second Skill');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Re-enter (no arrow keys this time). The active row must still be the
|
|
|
|
|
// SECOND skill, not the first — which is what the bug reproduced before.
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Review new skill');
|
|
|
|
|
// The preview header echoes the highlighted skill's name.
|
|
|
|
|
expect(frame).toContain('Second Skill');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 10:44:52 -07:00
|
|
|
describe('patch support', () => {
|
|
|
|
|
it('shows patches alongside skills with section headers', async () => {
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, unmount } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('New Skills');
|
|
|
|
|
expect(frame).toContain('Inbox Skill');
|
|
|
|
|
expect(frame).toContain('Skill Updates');
|
|
|
|
|
expect(frame).toContain('update-docs');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows diff preview when a patch is selected', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
|
|
|
|
async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('update-docs');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select the patch
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Review changes before applying');
|
|
|
|
|
expect(frame).toContain('Apply');
|
|
|
|
|
expect(frame).toContain('Dismiss');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies a patch when Apply is selected', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={onReloadSkills}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockListInboxPatches).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select the patch
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select "Apply"
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockApplyInboxPatch).toHaveBeenCalledWith(
|
|
|
|
|
config,
|
|
|
|
|
'update-docs.patch',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
expect(onReloadSkills).toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('disables Apply for workspace patches in an untrusted workspace', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(false),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
|
|
|
|
async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('workspace-update');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('Apply');
|
|
|
|
|
expect(frame).toContain(
|
|
|
|
|
'.gemini/skills — unavailable until this workspace is trusted',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses canonical project-scope checks before enabling Apply', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
|
|
|
|
mockIsProjectSkillPatchTarget.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(false),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockReturnValue('/symlinked/workspace/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
|
|
|
|
async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('workspace-update');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain(
|
|
|
|
|
'.gemini/skills — unavailable until this workspace is trusted',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
expect(mockIsProjectSkillPatchTarget).toHaveBeenCalledWith(
|
|
|
|
|
'/repo/.gemini/skills/docs-writer/SKILL.md',
|
|
|
|
|
config,
|
|
|
|
|
);
|
|
|
|
|
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('dismisses a patch when Dismiss is selected', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
|
|
|
|
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={onReloadSkills}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockListInboxPatches).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Select the patch
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Move down to "Dismiss" and select
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\x1b[B');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockDismissInboxPatch).toHaveBeenCalledWith(
|
|
|
|
|
config,
|
|
|
|
|
'update-docs.patch',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
expect(onReloadSkills).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows Windows patch entries with a basename and origin tag', async () => {
|
|
|
|
|
vi.stubEnv('USERPROFILE', 'C:\\Users\\sandy');
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([windowsGlobalPatch]);
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockReturnValue('C:\\repo\\.gemini\\skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, unmount } = await act(async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const frame = lastFrame();
|
|
|
|
|
expect(frame).toContain('[Global]');
|
|
|
|
|
expect(frame).toContain('SKILL.md');
|
|
|
|
|
expect(frame).not.toContain('C:\\Users\\sandy\\.gemini\\skills');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders multi-section patches without duplicate React keys', async () => {
|
|
|
|
|
mockListInboxSkills.mockResolvedValue([]);
|
|
|
|
|
mockListInboxPatches.mockResolvedValue([multiSectionPatch]);
|
|
|
|
|
|
|
|
|
|
const consoleErrorSpy = vi
|
|
|
|
|
.spyOn(console, 'error')
|
|
|
|
|
.mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
|
isTrustedFolder: vi.fn().mockReturnValue(true),
|
|
|
|
|
storage: {
|
|
|
|
|
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
|
|
|
|
},
|
|
|
|
|
} as unknown as Config;
|
|
|
|
|
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
|
|
|
|
async () =>
|
|
|
|
|
renderWithProviders(
|
2026-05-04 12:07:13 -07:00
|
|
|
<InboxDialog
|
2026-04-13 10:44:52 -07:00
|
|
|
config={config}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
|
|
|
|
/>,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('multi-section');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
stdin.write('\r');
|
|
|
|
|
await waitUntilReady();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(lastFrame()).toContain('Review changes before applying');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Encountered two children with the same key'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
|
unmount();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-08 11:08:49 -07:00
|
|
|
});
|