Canvas analysis previously sent every page. Add a Magic Notes setting for 1-8 canvas pages, defaulting to 1 for new and historical settings. Select the first N pages in current canvas order before capture and use the same text/image range for saved entries, drafts and canvas-source todos. Keep full 50-page saving/export, expose the same setting through config tools, and use the shared settings select with an inline help icon. Include pagination, persistence, production IPC and isolated Electron regressions, including text-only input and scaled page boundaries. Validation: focused feature tests and isolated Electron regressions passed; the final compact UI passed 14 tests. Typecheck and lint were reported passing by the coordinating agent. No full-suite pass is claimed because the completed full test command exposed no summary. No live model calls were made for this commit preparation. Release note: 魔法笔记新增“发送画布页数”设置,可选 1~8 页,默认 1 页,按当前画布顺序发送前 N 页的文字和图片;标题旁问号可查看说明,保存和导出完整画布不受影响。
582 lines
16 KiB
TypeScript
582 lines
16 KiB
TypeScript
import {
|
|
access,
|
|
mkdtemp,
|
|
mkdir,
|
|
rm,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { ApplicationSettingsStore } from './application-settings-store'
|
|
import {
|
|
BrowserProfileService,
|
|
MemoryBrowserProfileStore
|
|
} from './capabilities/browser-profile-service'
|
|
import {
|
|
CapabilityService,
|
|
type CapabilityCipher
|
|
} from './capabilities/capability-service'
|
|
import { GoodBuddyConfigService } from './goodbuddy-config-service'
|
|
|
|
const temporaryDirectories: string[] = []
|
|
const cipher: CapabilityCipher = {
|
|
isAvailable: () => true,
|
|
encrypt: (value) => Buffer.from(value),
|
|
decrypt: (value) => value.toString()
|
|
}
|
|
|
|
async function writeSkill(
|
|
root: string,
|
|
id: string,
|
|
body = 'Follow the user request.'
|
|
): Promise<string> {
|
|
const directory = join(root, id)
|
|
await mkdir(directory, { recursive: true })
|
|
await writeFile(
|
|
join(directory, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
`id: ${id}`,
|
|
`name: ${id}`,
|
|
`description: ${id} test skill`,
|
|
'---',
|
|
'',
|
|
body
|
|
].join('\n'),
|
|
'utf8'
|
|
)
|
|
return directory
|
|
}
|
|
|
|
async function createHarness(now = 1_000) {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-config-'))
|
|
temporaryDirectories.push(directory)
|
|
const builtinRoot = join(directory, 'builtin')
|
|
const importedRoot = join(directory, 'imported')
|
|
const workspace = join(directory, 'workspace')
|
|
await mkdir(workspace, { recursive: true })
|
|
await writeSkill(builtinRoot, 'built-in')
|
|
const capabilities = new CapabilityService(
|
|
join(directory, 'capabilities.json'),
|
|
builtinRoot,
|
|
importedRoot,
|
|
cipher,
|
|
{
|
|
browserProfiles: new BrowserProfileService(
|
|
new MemoryBrowserProfileStore()
|
|
)
|
|
}
|
|
)
|
|
const application = new ApplicationSettingsStore(
|
|
join(directory, 'application.json')
|
|
)
|
|
const service = new GoodBuddyConfigService(application, capabilities, {
|
|
now: () => now,
|
|
planTtlMs: 100
|
|
})
|
|
return {
|
|
directory,
|
|
workspace,
|
|
capabilities,
|
|
application,
|
|
service,
|
|
setNow(value: number) {
|
|
now = value
|
|
}
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { recursive: true, force: true }))
|
|
)
|
|
})
|
|
|
|
describe('GoodBuddyConfigService', () => {
|
|
it('publishes persisted application changes from config apply through the settings event', async () => {
|
|
const { application, service, workspace } = await createHarness()
|
|
const changed = vi.fn()
|
|
const unsubscribe = application.onChanged(changed)
|
|
const current = await application.get()
|
|
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
|
|
const plan = await service.plan('settings-sync', workspace, {
|
|
operations: [{ operation: 'application.update', updates: { applicationNavigation, magicNoteCanvasPageCount: 8 } }],
|
|
})
|
|
expect(changed).not.toHaveBeenCalled()
|
|
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal, async () => true)
|
|
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
|
|
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
|
|
expect((await service.getSnapshot()).application.magicNoteCanvasPageCount).toBe(8)
|
|
unsubscribe()
|
|
await application.update({ localInferenceEnabled: false })
|
|
expect(changed).toHaveBeenCalledOnce()
|
|
})
|
|
it('returns only sanitized configuration and publishes common examples', async () => {
|
|
const { service, capabilities } = await createHarness()
|
|
await capabilities.saveMcpServer(undefined, {
|
|
name: 'Secret MCP',
|
|
description: '',
|
|
enabled: false,
|
|
allowDynamicTools: false,
|
|
assignments: ['model'],
|
|
secret: { action: 'replace', value: 'never-return-this' },
|
|
transport: 'http',
|
|
url: 'https://mcp.example.com/tools'
|
|
})
|
|
|
|
expect(service.getCapabilities()).toMatchObject({
|
|
server: 'goodbuddy_config',
|
|
applyRequiresApproval: true,
|
|
operations: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
operation: 'skill.import',
|
|
exampleRequest: expect.stringContaining('导入')
|
|
})
|
|
])
|
|
})
|
|
const snapshot = await service.getSnapshot()
|
|
expect(snapshot.mcpServers[0]).toMatchObject({
|
|
name: 'Secret MCP',
|
|
secretConfigured: true,
|
|
transport: 'http'
|
|
})
|
|
expect(JSON.stringify(snapshot)).not.toContain('never-return-this')
|
|
expect(JSON.stringify(snapshot)).not.toContain(
|
|
'https://mcp.example.com/tools'
|
|
)
|
|
})
|
|
|
|
it('scopes one-shot plans to a request and rejects expired plans', async () => {
|
|
const harness = await createHarness()
|
|
const plan = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
const authorize = vi.fn(async () => true)
|
|
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-two',
|
|
{ planId: plan.planId },
|
|
new AbortController().signal,
|
|
authorize
|
|
)
|
|
).rejects.toThrow('不属于当前请求')
|
|
expect(authorize).not.toHaveBeenCalled()
|
|
|
|
const expiring = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
harness.setNow(1_101)
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-one',
|
|
{ planId: expiring.planId },
|
|
new AbortController().signal,
|
|
authorize
|
|
)
|
|
).rejects.toThrow('已过期')
|
|
expect(authorize).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a plan that expires while native approval is open', async () => {
|
|
const harness = await createHarness()
|
|
const plan = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-one',
|
|
{ planId: plan.planId },
|
|
new AbortController().signal,
|
|
async () => {
|
|
harness.setNow(1_101)
|
|
return true
|
|
}
|
|
)
|
|
).rejects.toThrow('确认期间已过期')
|
|
await expect(harness.application.get()).resolves.toMatchObject({
|
|
checkUpdatesOnStartup: true
|
|
})
|
|
})
|
|
|
|
it('requires approval and applies a plan only once', async () => {
|
|
const harness = await createHarness()
|
|
const deniedPlan = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-one',
|
|
{ planId: deniedPlan.planId },
|
|
new AbortController().signal,
|
|
async () => false
|
|
)
|
|
).rejects.toThrow('用户拒绝')
|
|
await expect(harness.application.get()).resolves.toMatchObject({
|
|
checkUpdatesOnStartup: true
|
|
})
|
|
|
|
const approvedPlan = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-one',
|
|
{ planId: approvedPlan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
).resolves.toMatchObject({
|
|
status: 'applied',
|
|
appliedOperations: 1,
|
|
reload: 'none'
|
|
})
|
|
await expect(harness.application.get()).resolves.toMatchObject({
|
|
checkUpdatesOnStartup: false
|
|
})
|
|
expect(harness.service.takePendingReload('request-one')).toBe(
|
|
'after-current-request'
|
|
)
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-one',
|
|
{ planId: approvedPlan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
).rejects.toThrow('不存在')
|
|
})
|
|
|
|
it('imports the exact inspected Skill and requests a deferred reload', async () => {
|
|
const harness = await createHarness()
|
|
await writeSkill(harness.workspace, 'meeting-helper')
|
|
const plan = await harness.service.plan(
|
|
'request-one',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: './meeting-helper',
|
|
enabled: false,
|
|
assignments: ['model']
|
|
}
|
|
]
|
|
}
|
|
)
|
|
expect(plan.operations[0]).toMatchObject({
|
|
sourcePath: './meeting-helper'
|
|
})
|
|
await harness.service.apply(
|
|
'request-one',
|
|
{ planId: plan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
await expect(harness.capabilities.getSnapshot()).resolves.toMatchObject({
|
|
skills: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'meeting-helper',
|
|
source: 'imported',
|
|
enabled: false,
|
|
assignments: ['model']
|
|
})
|
|
])
|
|
})
|
|
expect(harness.service.takePendingReload('request-one')).toBe(
|
|
'after-current-request'
|
|
)
|
|
expect(harness.service.takePendingReload('request-one')).toBe('none')
|
|
|
|
const secondSource = await writeSkill(
|
|
harness.workspace,
|
|
'changing-skill'
|
|
)
|
|
const changingPlan = await harness.service.plan(
|
|
'request-two',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: secondSource,
|
|
enabled: true,
|
|
assignments: ['model']
|
|
}
|
|
]
|
|
}
|
|
)
|
|
await writeFile(
|
|
join(secondSource, 'extra.js'),
|
|
'console.log("changed")',
|
|
'utf8'
|
|
)
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-two',
|
|
{ planId: changingPlan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
).rejects.toThrow('确认后已发生变化')
|
|
})
|
|
|
|
it('reports partial application and still requests reload after a later failure', async () => {
|
|
const harness = await createHarness()
|
|
await writeSkill(harness.workspace, 'partial-skill')
|
|
const plan = await harness.service.plan(
|
|
'request-partial',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: './partial-skill',
|
|
enabled: true,
|
|
assignments: ['model']
|
|
},
|
|
{
|
|
operation: 'application.update',
|
|
updates: { checkUpdatesOnStartup: false }
|
|
}
|
|
]
|
|
}
|
|
)
|
|
await writeFile(
|
|
join(harness.workspace, 'partial-skill', 'changed.txt'),
|
|
'changed',
|
|
'utf8'
|
|
)
|
|
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-partial',
|
|
{ planId: plan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
).rejects.toThrow('已发生变化')
|
|
|
|
const secondPlan = await harness.service.plan(
|
|
'request-partial-two',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: './partial-skill',
|
|
enabled: true,
|
|
assignments: ['model']
|
|
},
|
|
{
|
|
operation: 'skill.setEnabled',
|
|
skillId: 'built-in',
|
|
enabled: false
|
|
}
|
|
]
|
|
}
|
|
)
|
|
const originalSetSkillEnabled = harness.capabilities.setSkillEnabled.bind(
|
|
harness.capabilities
|
|
)
|
|
vi.spyOn(harness.capabilities, 'setSkillEnabled').mockImplementationOnce(
|
|
async () => {
|
|
throw new Error('later operation failed')
|
|
}
|
|
)
|
|
const result = await harness.service.apply(
|
|
'request-partial-two',
|
|
{ planId: secondPlan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
expect(result).toMatchObject({
|
|
status: 'partially-applied',
|
|
appliedOperations: 1,
|
|
reload: 'after-current-request',
|
|
error: 'later operation failed'
|
|
})
|
|
expect(harness.service.takePendingReload('request-partial-two')).toBe(
|
|
'after-current-request'
|
|
)
|
|
vi.mocked(harness.capabilities.setSkillEnabled).mockImplementation(
|
|
originalSetSkillEnabled
|
|
)
|
|
})
|
|
|
|
it('rejects Skill paths outside the workspace', async () => {
|
|
const harness = await createHarness()
|
|
const outside = await writeSkill(harness.directory, 'outside-skill')
|
|
await expect(
|
|
harness.service.plan('request-one', harness.workspace, {
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: outside,
|
|
enabled: true,
|
|
assignments: ['model']
|
|
}
|
|
]
|
|
})
|
|
).rejects.toThrow('必须位于当前工作区')
|
|
})
|
|
|
|
it('shows executable MCP details and invalidates hidden connection changes', async () => {
|
|
const harness = await createHarness()
|
|
const existing = await harness.capabilities.saveMcpServer(undefined, {
|
|
name: 'Existing MCP',
|
|
description: '',
|
|
enabled: false,
|
|
allowDynamicTools: false,
|
|
assignments: ['model'],
|
|
secret: { action: 'keep' },
|
|
transport: 'http',
|
|
url: 'https://old.example.com/mcp'
|
|
})
|
|
const serverId = existing.mcpServers[0]!.id
|
|
const addPlan = await harness.service.plan(
|
|
'request-add',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'mcp.add',
|
|
connection: {
|
|
name: 'Local MCP',
|
|
description: '',
|
|
allowDynamicTools: false,
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js', '--safe']
|
|
},
|
|
enabled: false,
|
|
assignments: ['model']
|
|
}
|
|
]
|
|
}
|
|
)
|
|
expect(addPlan.steps[0]?.summary).toContain(
|
|
'命令 "node" "server.js" "--safe"'
|
|
)
|
|
|
|
const enablePlan = await harness.service.plan(
|
|
'request-enable',
|
|
harness.workspace,
|
|
{
|
|
operations: [
|
|
{
|
|
operation: 'mcp.setEnabled',
|
|
serverId,
|
|
enabled: true
|
|
}
|
|
]
|
|
}
|
|
)
|
|
await harness.capabilities.saveMcpServer(serverId, {
|
|
name: 'Existing MCP',
|
|
description: '',
|
|
enabled: false,
|
|
allowDynamicTools: false,
|
|
assignments: ['model'],
|
|
secret: { action: 'keep' },
|
|
transport: 'http',
|
|
url: 'https://new.example.com/mcp'
|
|
})
|
|
await expect(
|
|
harness.service.apply(
|
|
'request-enable',
|
|
{ planId: enablePlan.planId },
|
|
new AbortController().signal,
|
|
async () => true
|
|
)
|
|
).rejects.toThrow('确认前已发生变化')
|
|
})
|
|
|
|
it('does not create persistent inspection artifacts while planning a ZIP Skill', async () => {
|
|
const harness = await createHarness()
|
|
const archivePath = join(harness.workspace, 'skill.zip')
|
|
const { strToU8, zipSync } = await import('fflate')
|
|
await writeFile(
|
|
archivePath,
|
|
Buffer.from(
|
|
zipSync({
|
|
'zip-helper/SKILL.md': strToU8(
|
|
[
|
|
'---',
|
|
'id: zip-helper',
|
|
'name: zip-helper',
|
|
'description: ZIP helper',
|
|
'---',
|
|
'',
|
|
'Help with ZIP files.'
|
|
].join('\n')
|
|
)
|
|
})
|
|
)
|
|
)
|
|
|
|
await harness.service.plan('request-zip', harness.workspace, {
|
|
operations: [
|
|
{
|
|
operation: 'skill.import',
|
|
sourcePath: './skill.zip',
|
|
enabled: false,
|
|
assignments: []
|
|
}
|
|
]
|
|
})
|
|
await expect(
|
|
access(join(harness.directory, 'imported'))
|
|
).rejects.toThrow()
|
|
})
|
|
})
|