Release v0.4.0
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { collectUuidReferences } = require('../lib/tools/assets-advanced');
|
||||
|
||||
test('collectUuidReferences finds structured and literal UUID references', () => {
|
||||
const refs = collectUuidReferences(JSON.stringify({
|
||||
__type__: 'cc.Prefab',
|
||||
sprite: { __uuid__: '2d3KcYpS5HCKb6wU0v5c9x' },
|
||||
nested: [{ assetUuid: '550e8400-e29b-41d4-a716-446655440000' }],
|
||||
}));
|
||||
|
||||
assert.equal(refs.some((ref) => ref.uuid === '2d3KcYpS5HCKb6wU0v5c9x' && ref.source === 'structured'), true);
|
||||
assert.equal(refs.some((ref) => ref.uuid === '550e8400-e29b-41d4-a716-446655440000'), true);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
broadcastEditorMessage,
|
||||
getEditorPreference,
|
||||
setEditorPreference,
|
||||
tryEditorRequests,
|
||||
tryEditorRequestsStatus,
|
||||
} = require('../lib/tools/cocos-project');
|
||||
|
||||
test('tryEditorRequests returns the first successful editor message candidate', async () => {
|
||||
const calls = [];
|
||||
global.Editor = {
|
||||
Message: {
|
||||
request: async (channel, method, payload) => {
|
||||
calls.push({ channel, method, payload });
|
||||
if (method === 'bad') {
|
||||
throw new Error('nope');
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tryEditorRequests([
|
||||
{ channel: 'scene', method: 'bad' },
|
||||
{ channel: 'scene', method: 'save-scene', args: [{ force: true }] },
|
||||
]);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.method, 'save-scene');
|
||||
assert.equal(calls.length, 2);
|
||||
} finally {
|
||||
delete global.Editor;
|
||||
}
|
||||
});
|
||||
|
||||
test('tryEditorRequestsStatus returns an unavailable payload instead of throwing', async () => {
|
||||
global.Editor = {
|
||||
Message: {
|
||||
request: async () => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tryEditorRequestsStatus([{ channel: 'builder', method: 'query-build-status' }]);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.available, false);
|
||||
assert.equal(result.attempts.length, 1);
|
||||
} finally {
|
||||
delete global.Editor;
|
||||
}
|
||||
});
|
||||
|
||||
test('preference helpers and broadcast use available Editor APIs', () => {
|
||||
const sent = [];
|
||||
const store = new Map();
|
||||
global.Editor = {
|
||||
Message: {
|
||||
send(channel, message, payload) {
|
||||
sent.push({ channel, message, payload });
|
||||
},
|
||||
},
|
||||
Profile: {
|
||||
getProject(key) {
|
||||
return store.get(key);
|
||||
},
|
||||
setProject(key, value) {
|
||||
store.set(key, value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
setEditorPreference('project', 'preview.port', 7456);
|
||||
assert.equal(getEditorPreference('project', 'preview.port'), 7456);
|
||||
|
||||
const result = broadcastEditorMessage({ channel: 'scene', message: 'custom-event', payload: { ok: true } });
|
||||
assert.equal(result.sent, true);
|
||||
assert.deepEqual(sent[0], { channel: 'scene', message: 'custom-event', payload: { ok: true } });
|
||||
} finally {
|
||||
delete global.Editor;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
assertJavascriptSafety,
|
||||
inspectJavascriptSafety,
|
||||
} = require('../lib/javascript-safety');
|
||||
|
||||
const PROJECT_PATH = path.resolve('/tmp/funplay-cocos-test-project');
|
||||
|
||||
test('JavaScript safety allows project-local write snippets by default', () => {
|
||||
const result = inspectJavascriptSafety(
|
||||
"fs.writeFileSync(path.join(context.projectPath, 'assets/generated.ts'), 'export {};');",
|
||||
{ projectPath: PROJECT_PATH }
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('JavaScript safety blocks delete operations', () => {
|
||||
assert.throws(
|
||||
() => assertJavascriptSafety("fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });", {
|
||||
projectPath: PROJECT_PATH,
|
||||
}),
|
||||
/delete\/truncate/
|
||||
);
|
||||
});
|
||||
|
||||
test('JavaScript safety blocks traversal and home path literals', () => {
|
||||
const result = inspectJavascriptSafety(
|
||||
"fs.writeFileSync('../outside.txt', 'x'); fs.writeFileSync('~/secret.txt', 'x');",
|
||||
{ projectPath: PROJECT_PATH }
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.violations.some((item) => item.includes('path traversal')), true);
|
||||
assert.equal(result.violations.some((item) => item.includes('user-home')), true);
|
||||
});
|
||||
|
||||
test('JavaScript safety blocks absolute paths outside the project', () => {
|
||||
const result = inspectJavascriptSafety("fs.writeFileSync('/tmp/outside.txt', 'x');", {
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.violations.some((item) => item.includes('absolute path outside')), true);
|
||||
});
|
||||
|
||||
test('JavaScript safety blocks child_process usage', () => {
|
||||
assert.throws(
|
||||
() => assertJavascriptSafety("const cp = require('child_process'); cp.execSync('rm -rf /tmp/x');", {
|
||||
projectPath: PROJECT_PATH,
|
||||
}),
|
||||
/child_process/
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createCocosMcpProjectSkill,
|
||||
createProjectSkill,
|
||||
listProjectInstructions,
|
||||
readProjectInstruction,
|
||||
@@ -39,6 +40,16 @@ test('createProjectSkill writes a Codex project skill', () => {
|
||||
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
|
||||
});
|
||||
|
||||
test('createCocosMcpProjectSkill writes the recommended MCP workflow skill', () => {
|
||||
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-default-skill-'));
|
||||
const result = createCocosMcpProjectSkill(projectPath);
|
||||
|
||||
assert.equal(result.path, '.codex/skills/funplay-cocos-mcp-workflow/SKILL.md');
|
||||
const content = readProjectInstruction(projectPath, result.path).content;
|
||||
assert.match(content, /Funplay Cocos MCP Workflow/);
|
||||
assert.match(content, /inspect_asset_dependencies/);
|
||||
});
|
||||
|
||||
test('project instruction helpers reject traversal outside the project', () => {
|
||||
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
|
||||
assert.throws(
|
||||
|
||||
+67
-3
@@ -9,7 +9,7 @@ const {
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
} = require('../lib/server');
|
||||
|
||||
function createServer(toolRegistry = {}, config = {}) {
|
||||
function createServer(toolRegistry = {}, config = {}, options = {}) {
|
||||
return new McpServer({
|
||||
config: { host: '127.0.0.1', port: 8765, ...config },
|
||||
toolRegistry: {
|
||||
@@ -28,8 +28,10 @@ function createServer(toolRegistry = {}, config = {}) {
|
||||
},
|
||||
interactionLog: { add() {} },
|
||||
runtimeLog: { add() {} },
|
||||
serverName: 'test-server',
|
||||
serverVersion: '0.0.0-test',
|
||||
serverName: options.serverName || 'test-server',
|
||||
serverVersion: options.serverVersion || '0.0.0-test',
|
||||
projectName: options.projectName || 'test-project',
|
||||
projectIdentity: options.projectIdentity || 'test-project-id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ test('initialize negotiates the current MCP protocol version by default', async
|
||||
|
||||
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
|
||||
assert.equal(response.result.serverInfo.name, 'test-server');
|
||||
assert.equal(response.result.funplay.projectIdentity, 'test-project-id');
|
||||
});
|
||||
|
||||
test('initialize can negotiate an older supported MCP protocol version', async () => {
|
||||
@@ -286,6 +289,67 @@ test('HTTP GET /tools returns debug tool metadata and curl examples', async () =
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP GET /health returns project identity metadata', async () => {
|
||||
const server = createServer({}, { port: 0 }, { projectIdentity: 'health-project-id' });
|
||||
await server.start();
|
||||
try {
|
||||
const response = await httpGet(server.getPort(), '/health');
|
||||
const payload = JSON.parse(response.body);
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.projectName, 'test-project');
|
||||
assert.equal(payload.projectIdentity, 'health-project-id');
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('start attaches to an existing same-project listener on the configured port', async () => {
|
||||
const owner = createServer({}, { port: 0 }, { projectIdentity: 'same-project' });
|
||||
await owner.start();
|
||||
const attached = createServer({}, { port: owner.getPort() }, { projectIdentity: 'same-project' });
|
||||
|
||||
try {
|
||||
await attached.start();
|
||||
|
||||
assert.equal(attached.isRunning(), true);
|
||||
assert.equal(attached.getPort(), owner.getPort());
|
||||
assert.equal(attached.getAttachInfo().projectIdentity, 'same-project');
|
||||
|
||||
await attached.stop();
|
||||
assert.equal(attached.isRunning(), false);
|
||||
|
||||
const response = await httpGet(owner.getPort(), '/health');
|
||||
assert.equal(response.statusCode, 200);
|
||||
} finally {
|
||||
if (attached.isRunning()) {
|
||||
await attached.stop();
|
||||
}
|
||||
await owner.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('start falls back instead of attaching to a different project listener', async () => {
|
||||
const owner = createServer({}, { port: 0 }, { projectIdentity: 'owner-project' });
|
||||
await owner.start();
|
||||
const contender = createServer({}, { port: owner.getPort() }, { projectIdentity: 'other-project' });
|
||||
|
||||
try {
|
||||
await contender.start();
|
||||
|
||||
assert.equal(contender.isRunning(), true);
|
||||
assert.notEqual(contender.getPort(), owner.getPort());
|
||||
assert.equal(contender.getAttachInfo(), null);
|
||||
assert.equal(contender.getPortFallbackInfo().requestedPort, owner.getPort());
|
||||
} finally {
|
||||
if (contender.isRunning()) {
|
||||
await contender.stop();
|
||||
}
|
||||
await owner.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
|
||||
const server = createServer({}, { port: 0, enableSessions: true });
|
||||
await server.start();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
applyToolProfile,
|
||||
createToolProfileSnapshot,
|
||||
deleteToolProfile,
|
||||
exportToolProfiles,
|
||||
importToolProfiles,
|
||||
normalizeSavedToolProfiles,
|
||||
upsertToolProfile,
|
||||
} = require('../lib/tool-profiles');
|
||||
|
||||
test('tool profiles normalize, upsert, and dedupe by name', () => {
|
||||
const profiles = normalizeSavedToolProfiles([
|
||||
{ name: 'QA', toolProfile: 'custom', enabledToolCategories: 'assets\nlogs' },
|
||||
{ name: 'qa', toolProfile: 'full', disabledTools: ['delete_asset'] },
|
||||
{ name: '' },
|
||||
]);
|
||||
|
||||
assert.equal(profiles.length, 1);
|
||||
assert.equal(profiles[0].name, 'qa');
|
||||
assert.equal(profiles[0].toolProfile, 'full');
|
||||
|
||||
const updated = upsertToolProfile(profiles, {
|
||||
name: 'Prototype',
|
||||
toolProfile: 'custom',
|
||||
enabledToolCategories: ['ui'],
|
||||
});
|
||||
|
||||
assert.equal(updated.length, 2);
|
||||
assert.equal(updated.some((profile) => profile.name === 'Prototype'), true);
|
||||
});
|
||||
|
||||
test('tool profiles snapshot and apply exposure config', () => {
|
||||
const snapshot = createToolProfileSnapshot({
|
||||
toolProfile: 'custom',
|
||||
enabledToolCategories: ['assets'],
|
||||
disabledToolCategories: ['input'],
|
||||
enabledTools: ['write_file'],
|
||||
disabledTools: ['delete_asset'],
|
||||
}, 'Asset QA');
|
||||
|
||||
const applied = applyToolProfile({ port: 8765 }, snapshot);
|
||||
assert.equal(applied.port, 8765);
|
||||
assert.equal(applied.activeToolProfileName, 'Asset QA');
|
||||
assert.deepEqual(applied.enabledToolCategories, ['assets']);
|
||||
assert.deepEqual(applied.disabledTools, ['delete_asset']);
|
||||
});
|
||||
|
||||
test('tool profiles import, export, and delete', () => {
|
||||
const imported = importToolProfiles([], JSON.stringify({
|
||||
version: 1,
|
||||
profiles: [
|
||||
{ name: 'Core QA', toolProfile: 'core' },
|
||||
{ name: 'Debug', toolProfile: 'custom', enabledToolCategories: ['logs', 'diagnostics'] },
|
||||
],
|
||||
}));
|
||||
|
||||
assert.equal(imported.length, 2);
|
||||
assert.deepEqual(exportToolProfiles(imported).profiles.map((profile) => profile.name), ['Core QA', 'Debug']);
|
||||
|
||||
const remaining = deleteToolProfile(imported, 'Debug');
|
||||
assert.deepEqual(remaining.map((profile) => profile.name), ['Core QA']);
|
||||
});
|
||||
@@ -21,11 +21,13 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
|
||||
|
||||
test('core profile exposes the documented focused tool set', () => {
|
||||
const tools = createRegistry('core').listTools();
|
||||
assert.equal(tools.length, 34);
|
||||
assert.equal(tools.length, 37);
|
||||
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'inspect_asset_dependencies'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_build_status'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
|
||||
@@ -34,10 +36,14 @@ test('core profile exposes the documented focused tool set', () => {
|
||||
|
||||
test('full profile exposes all built-in tools', () => {
|
||||
const tools = createRegistry('full').listTools();
|
||||
assert.equal(tools.length, 89);
|
||||
assert.equal(tools.length, 101);
|
||||
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'create_cocos_mcp_project_skill'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'bind_button_click_event'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'open_build_panel'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'broadcast_editor_message'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
|
||||
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
|
||||
});
|
||||
@@ -98,3 +104,34 @@ test('callToolDetailed preserves screenshot image text while keeping structured
|
||||
assert.equal(result.value.data.image, true);
|
||||
assert.equal(result.value.data.mimeType, 'image/png');
|
||||
});
|
||||
|
||||
test('execute_javascript safety checks block risky editor snippets by default', async () => {
|
||||
const registry = createRegistry('core');
|
||||
|
||||
await assert.rejects(
|
||||
() => registry.callToolDetailed('execute_javascript', {
|
||||
context: 'editor',
|
||||
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
|
||||
}),
|
||||
/JavaScript safety checks blocked/
|
||||
);
|
||||
});
|
||||
|
||||
test('execute_javascript safety checks can be explicitly disabled per call', async () => {
|
||||
let called = false;
|
||||
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
|
||||
editorExecutor: async () => {
|
||||
called = true;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await registry.callToolDetailed('execute_javascript', {
|
||||
context: 'editor',
|
||||
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
|
||||
safety_checks: false,
|
||||
});
|
||||
|
||||
assert.equal(called, true);
|
||||
assert.equal(result.value.ok, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user