/**
* Scene Tools Integration Tests
* Comprehensive ISO/IEC 29119 compliant test coverage for scene manipulation tools
*
* Tools covered:
* - AddNodeTool
* - CreateSceneTool
* - DuplicateNodeTool
* - EditNodeTool
* - InstanceSceneTool
* - LoadSpriteTool
* - ManageGroupsTool
* - RemoveNodeTool
* - SaveSceneTool
* - RenameNodeTool
* - MoveNodeTool
* - ConnectSignalTool
* - GetNodeTreeTool
* - ListScenesTool
*
* Test categories per ISO 29119:
* 1. Input validation (Zod schema validation)
* 2. Missing required parameters
* 3. Invalid parameter values
* 4. Path security (path traversal prevention)
* 5. Success scenarios
* 6. Error handling
* 7. Boundary conditions
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import {
createTempProject,
getResponseText,
parseJsonResponse,
isErrorResponse,
} from '../test-utils.js';
// Import all scene tool handlers
import { handleAddNode } from './AddNodeTool.js';
import { handleCreateScene } from './CreateSceneTool.js';
import { handleDuplicateNode } from './DuplicateNodeTool.js';
import { handleEditNode } from './EditNodeTool.js';
import { handleInstanceScene } from './InstanceSceneTool.js';
import { handleLoadSprite } from './LoadSpriteTool.js';
import { handleManageGroups } from './ManageGroupsTool.js';
import { handleRemoveNode } from './RemoveNodeTool.js';
import { handleSaveScene } from './SaveSceneTool.js';
import { handleRenameNode } from './RenameNodeTool.js';
import { handleMoveNode } from './MoveNodeTool.js';
import { handleConnectSignal } from './ConnectSignalTool.js';
import { handleGetNodeTree } from './GetNodeTreeTool.js';
import { handleListScenes } from './ListScenesTool.js';
// ============================================================================
// Test Fixtures
// ============================================================================
/**
* Additional scene fixture for instancing tests
*/
const PLAYER_SCENE_FIXTURE = `[gd_scene format=3 uid="uid://player123"]
[node name="Player" type="CharacterBody2D"]
[node name="Sprite" type="Sprite2D" parent="."]
[node name="CollisionShape" type="CollisionShape2D" parent="."]
`;
/**
* Scene with groups
*/
const SCENE_WITH_GROUPS_FIXTURE = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Enemy" type="CharacterBody2D" parent="." groups=["enemies", "damageable"]]
[node name="Coin" type="Area2D" parent="." groups=["collectibles"]]
`;
/**
* Simple PNG header for texture tests (1x1 transparent PNG)
*/
const MINIMAL_PNG = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
]);
// ============================================================================
// Test Suite
// ============================================================================
describe('Scene Tools', () => {
let projectPath: string;
let cleanup: () => void;
beforeEach(() => {
const temp = createTempProject();
projectPath = temp.projectPath;
cleanup = temp.cleanup;
// Add additional fixtures
writeFileSync(join(projectPath, 'scenes', 'player.tscn'), PLAYER_SCENE_FIXTURE);
writeFileSync(join(projectPath, 'scenes', 'with_groups.tscn'), SCENE_WITH_GROUPS_FIXTURE);
// Create assets directory with texture
mkdirSync(join(projectPath, 'assets'), { recursive: true });
writeFileSync(join(projectPath, 'assets', 'icon.png'), MINIMAL_PNG);
});
afterEach(() => {
cleanup();
});
// ==========================================================================
// AddNodeTool Tests
// ==========================================================================
describe('AddNodeTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleAddNode({
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleAddNode({
projectPath,
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodeType', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodeName', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty nodeType', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: '',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty nodeName', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleAddNode({
projectPath: '../../../etc/passwd',
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in scenePath', async () => {
const result = await handleAddNode({
projectPath,
scenePath: '../../../etc/passwd',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('project validation', () => {
it('should reject non-existent project', async () => {
const result = await handleAddNode({
projectPath: '/non/existent/project',
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist|invalid|not a valid/i);
});
it('should reject non-existent scene', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/nonexistent.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
});
describe('valid node types', () => {
const nodeTypes = ['Node2D', 'Node3D', 'Sprite2D', 'CharacterBody2D', 'Area2D', 'RigidBody2D'];
nodeTypes.forEach((nodeType) => {
it(`should accept valid node type: ${nodeType}`, async () => {
// This test validates input acceptance - actual execution requires Godot
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType,
nodeName: 'TestNode',
});
// Should either succeed or fail due to Godot execution, not validation
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
});
});
describe('optional parameters', () => {
it('should accept parentNodePath parameter', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
parentNodePath: 'Player',
});
// Validation should pass
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
it('should accept properties parameter', async () => {
const result = await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: 'Sprite2D',
nodeName: 'NewSprite',
properties: { visible: true, modulate: { r: 1, g: 0, b: 0, a: 1 } },
});
// Validation should pass
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
});
});
// ==========================================================================
// CreateSceneTool Tests
// ==========================================================================
describe('CreateSceneTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleCreateScene({
scenePath: 'scenes/new.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleCreateScene({
projectPath,
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty projectPath', async () => {
const result = await handleCreateScene({
projectPath: '',
scenePath: 'scenes/new.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty scenePath', async () => {
const result = await handleCreateScene({
projectPath,
scenePath: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleCreateScene({
projectPath: '../../../malicious',
scenePath: 'scenes/new.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in scenePath', async () => {
const result = await handleCreateScene({
projectPath,
scenePath: '../../outside/malicious.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('project validation', () => {
it('should reject non-existent project directory', async () => {
const result = await handleCreateScene({
projectPath: '/non/existent/path',
scenePath: 'scenes/new.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist|invalid|not a valid/i);
});
});
describe('rootNodeType parameter', () => {
it('should default to Node2D when rootNodeType not specified', async () => {
const result = await handleCreateScene({
projectPath,
scenePath: 'scenes/new_default.tscn',
});
// If Godot is not available, validation still passes
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
const validRootTypes = ['Node2D', 'Node3D', 'Control', 'Node'];
validRootTypes.forEach((rootType, index) => {
it(`should accept rootNodeType: ${rootType}`, async () => {
const result = await handleCreateScene({
projectPath,
scenePath: `scenes/new_${rootType.toLowerCase()}_${index}.tscn`,
rootNodeType: rootType,
});
// Validation should pass
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
});
});
});
// ==========================================================================
// DuplicateNodeTool Tests
// ==========================================================================
describe('DuplicateNodeTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleDuplicateNode({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleDuplicateNode({
projectPath,
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty nodePath', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in scenePath', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: '../../../etc/passwd',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('root node protection', () => {
it('should reject duplicating root node with "."', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: '.',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/root node|cannot duplicate/i);
});
it('should reject duplicating root node with "root"', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'root',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/root node|cannot duplicate/i);
});
});
describe('duplicate operations', () => {
it('should duplicate node successfully', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('duplicated successfully');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('Player2');
});
it('should use custom newName when provided', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'ClonedPlayer',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('ClonedPlayer');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('ClonedPlayer');
});
it('should handle non-existent node', async () => {
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'NonExistent',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
it('should generate unique name when duplicate exists', async () => {
// First duplication
await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
// Second duplication
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(false);
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toMatch(/Player3/);
});
});
});
// ==========================================================================
// EditNodeTool Tests
// ==========================================================================
describe('EditNodeTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleEditNode({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleEditNode({
projectPath,
nodePath: 'Player',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleEditNode({
projectPath,
scenePath: 'scenes/main.tscn',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing properties', async () => {
const result = await handleEditNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty nodePath', async () => {
const result = await handleEditNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: '',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleEditNode({
projectPath: '../../../etc',
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in scenePath', async () => {
const result = await handleEditNode({
projectPath,
scenePath: '../../../etc/passwd',
nodePath: 'Player',
properties: { visible: false },
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('properties validation', () => {
it('should accept various property types', async () => {
const result = await handleEditNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
properties: {
visible: true,
position: { x: 100, y: 200 },
scale: { x: 2, y: 2 },
modulate: { r: 1, g: 0.5, b: 0, a: 1 },
rotation: 45,
name: 'Player',
},
});
// Should pass validation even if Godot fails
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
});
});
// ==========================================================================
// InstanceSceneTool Tests
// ==========================================================================
describe('InstanceSceneTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleInstanceScene({
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/player.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleInstanceScene({
projectPath,
instancePath: 'scenes/player.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing instancePath', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in instancePath', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: '../../../etc/passwd',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('self-instancing protection', () => {
it('should handle instancing scene into itself', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/main.tscn',
});
// Self-instancing should be rejected, but the behavior depends on path normalization
// If the tool allows it, it may create a circular reference which Godot handles
const text = getResponseText(result);
if (isErrorResponse(result)) {
expect(text).toMatch(/itself|Cannot instance|same scene|circular/i);
} else {
// If it succeeded, ensure it was actually instanced (edge case behavior)
expect(text).toContain('instanced');
}
});
});
describe('instance operations', () => {
it('should instance scene successfully', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/player.tscn',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('instanced successfully');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('player.tscn');
});
it('should use custom instance name when provided', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/player.tscn',
instanceName: 'PlayerInstance',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('PlayerInstance');
});
it('should reject non-existent instance scene', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/nonexistent.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
it('should reject non-existent parent node', async () => {
const result = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/player.tscn',
parentNodePath: 'NonExistentParent',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
});
});
// ==========================================================================
// LoadSpriteTool Tests
// ==========================================================================
describe('LoadSpriteTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleLoadSprite({
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
texturePath: 'assets/icon.png',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleLoadSprite({
projectPath,
nodePath: 'Sprite',
texturePath: 'assets/icon.png',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/main.tscn',
texturePath: 'assets/icon.png',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing texturePath', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty texturePath', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
texturePath: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in texturePath', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
texturePath: '../../../etc/passwd',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('file validation', () => {
it('should reject non-existent texture file', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
texturePath: 'assets/nonexistent.png',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
it('should reject non-existent scene', async () => {
const result = await handleLoadSprite({
projectPath,
scenePath: 'scenes/nonexistent.tscn',
nodePath: 'Sprite',
texturePath: 'assets/icon.png',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
});
});
// ==========================================================================
// ManageGroupsTool Tests
// ==========================================================================
describe('ManageGroupsTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleManageGroups({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'list',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleManageGroups({
projectPath,
nodePath: 'Player',
action: 'list',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
action: 'list',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing action', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject invalid action', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'invalid_action' as 'add' | 'remove' | 'list',
});
expect(isErrorResponse(result)).toBe(true);
});
});
describe('path security', () => {
it('should reject path traversal in scenePath', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: '../../../etc/passwd',
nodePath: 'Player',
action: 'list',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('list action', () => {
it('should list groups for node with groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/with_groups.tscn',
nodePath: 'Enemy',
action: 'list',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{ groups: string[]; groupCount: number }>(result);
expect(data.groups).toContain('enemies');
expect(data.groups).toContain('damageable');
expect(data.groupCount).toBe(2);
});
it('should return empty list for node without groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'list',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{ groups: string[]; groupCount: number }>(result);
expect(data.groups).toEqual([]);
expect(data.groupCount).toBe(0);
});
});
describe('add action', () => {
it('should add group to node', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'add',
groups: ['players', 'controllable'],
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('added successfully');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('players');
expect(content).toContain('controllable');
});
it('should reject add action without groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'add',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/required|groups/i);
});
it('should reject add action with empty groups array', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'add',
groups: [],
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/required|groups/i);
});
it('should handle already existing groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/with_groups.tscn',
nodePath: 'Enemy',
action: 'add',
groups: ['enemies'], // Already exists
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/already|specified groups/i);
});
});
describe('remove action', () => {
it('should remove group from node', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/with_groups.tscn',
nodePath: 'Enemy',
action: 'remove',
groups: ['enemies'],
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('removed successfully');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/with_groups.tscn'), 'utf-8');
expect(content).not.toMatch(/groups=\["enemies"/);
});
it('should reject remove action without groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/with_groups.tscn',
nodePath: 'Enemy',
action: 'remove',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/required|groups/i);
});
it('should handle non-existent groups', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/with_groups.tscn',
nodePath: 'Enemy',
action: 'remove',
groups: ['nonexistent_group'],
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not in|specified groups/i);
});
});
describe('node validation', () => {
it('should handle non-existent node', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'NonExistent',
action: 'list',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
});
});
// ==========================================================================
// RemoveNodeTool Tests
// ==========================================================================
describe('RemoveNodeTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleRemoveNode({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleRemoveNode({
projectPath,
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleRemoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty nodePath', async () => {
const result = await handleRemoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleRemoveNode({
projectPath: '../../../etc',
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in scenePath', async () => {
const result = await handleRemoveNode({
projectPath,
scenePath: '../../etc/passwd',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('project validation', () => {
it('should reject non-existent project', async () => {
const result = await handleRemoveNode({
projectPath: '/non/existent/path',
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist|invalid|not a valid/i);
});
it('should reject non-existent scene', async () => {
const result = await handleRemoveNode({
projectPath,
scenePath: 'scenes/nonexistent.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
});
});
// ==========================================================================
// SaveSceneTool Tests
// ==========================================================================
describe('SaveSceneTool', () => {
describe('input validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleSaveScene({
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleSaveScene({
projectPath,
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject empty scenePath', async () => {
const result = await handleSaveScene({
projectPath,
scenePath: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
});
describe('path security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleSaveScene({
projectPath: '../../../etc',
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in scenePath', async () => {
const result = await handleSaveScene({
projectPath,
scenePath: '../../../etc/malicious.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
it('should reject path traversal in newPath', async () => {
const result = await handleSaveScene({
projectPath,
scenePath: 'scenes/main.tscn',
newPath: '../../../outside/malicious.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('project validation', () => {
it('should reject non-existent project', async () => {
const result = await handleSaveScene({
projectPath: '/non/existent/path',
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist|invalid|not a valid/i);
});
it('should reject non-existent scene', async () => {
const result = await handleSaveScene({
projectPath,
scenePath: 'scenes/nonexistent.tscn',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
});
describe('save as variant', () => {
it('should accept newPath for save as', async () => {
const result = await handleSaveScene({
projectPath,
scenePath: 'scenes/main.tscn',
newPath: 'scenes/main_copy.tscn',
});
// Validation should pass even if Godot fails
if (isErrorResponse(result)) {
expect(getResponseText(result)).not.toContain('Validation failed');
}
});
});
});
// ==========================================================================
// RenameNode Tests (from existing tests)
// ==========================================================================
describe('RenameNode', () => {
describe('validation', () => {
it('should return error when projectPath is missing', async () => {
const result = await handleRenameNode({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
});
it('should return error when scenePath is missing', async () => {
const result = await handleRenameNode({
projectPath,
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
});
it('should return error when newName is empty', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: '',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/required|empty|too small|>=1/i);
});
it('should return error for invalid node name characters', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Invalid/Name',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
});
describe('rename operations', () => {
it('should rename node successfully', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('renamed successfully');
expect(getResponseText(result)).toContain('Enemy');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('name="Enemy"');
});
it('should return error when node not found', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'NonExistentNode',
newName: 'NewName',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
});
});
// ==========================================================================
// MoveNode Tests (from existing tests)
// ==========================================================================
describe('MoveNode', () => {
describe('validation', () => {
it('should return error when moving root node', async () => {
const result = await handleMoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: '.',
newParentPath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('root node');
});
it('should return error when nodePath is missing', async () => {
const result = await handleMoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
newParentPath: '.',
});
expect(isErrorResponse(result)).toBe(true);
});
});
describe('move operations', () => {
it('should handle move node request', async () => {
const result = await handleMoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Sprite',
newParentPath: '.',
});
const text = getResponseText(result);
expect(text).toMatch(/moved successfully|not found/i);
});
it('should return error when node not found', async () => {
const result = await handleMoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'NonExistent',
newParentPath: '.',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
it('should return error when new parent not found', async () => {
const result = await handleMoveNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newParentPath: 'NonExistentParent',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('not found');
});
});
});
// ==========================================================================
// ConnectSignal Tests (from existing tests)
// ==========================================================================
describe('ConnectSignal', () => {
describe('validation', () => {
it('should return error when fromNodePath is missing', async () => {
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
signal: 'pressed',
toNodePath: '.',
method: '_on_pressed',
});
expect(isErrorResponse(result)).toBe(true);
});
it('should return error when signal is missing', async () => {
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'Player',
toNodePath: '.',
method: '_on_pressed',
});
expect(isErrorResponse(result)).toBe(true);
});
});
describe('connection operations', () => {
it('should connect signal successfully', async () => {
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'Player',
signal: 'ready',
toNodePath: '.',
method: '_on_player_ready',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('connected successfully');
// Verify file was modified
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('[connection');
expect(content).toContain('signal="ready"');
expect(content).toContain('method="_on_player_ready"');
});
it('should return error when source node not found', async () => {
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'NonExistent',
signal: 'pressed',
toNodePath: '.',
method: '_on_pressed',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Source node not found');
});
it('should return error when target node not found', async () => {
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'Player',
signal: 'pressed',
toNodePath: 'NonExistent',
method: '_on_pressed',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Target node not found');
});
it('should return error for duplicate connection', async () => {
// First connection
await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'Player',
signal: 'tree_entered',
toNodePath: '.',
method: '_on_tree_entered',
});
// Duplicate connection
const result = await handleConnectSignal({
projectPath,
scenePath: 'scenes/main.tscn',
fromNodePath: 'Player',
signal: 'tree_entered',
toNodePath: '.',
method: '_on_tree_entered',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('already exists');
});
});
});
// ==========================================================================
// GetNodeTree Tests (from existing tests)
// ==========================================================================
describe('GetNodeTree', () => {
describe('validation', () => {
it('should return error when scenePath is missing', async () => {
const result = await handleGetNodeTree({
projectPath,
});
expect(isErrorResponse(result)).toBe(true);
});
it('should return error for non-existent scene', async () => {
const result = await handleGetNodeTree({
projectPath,
scenePath: 'nonexistent.tscn',
});
expect(isErrorResponse(result)).toBe(true);
});
});
describe('tree retrieval', () => {
it('should return node tree for valid scene', async () => {
const result = await handleGetNodeTree({
projectPath,
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{
tree: { name: string; type: string; children: unknown[] };
nodeCount: number;
display: string;
}>(result);
expect(data.tree).toBeDefined();
expect(data.tree.name).toBe('Root');
expect(data.tree.type).toBe('Node2D');
expect(data.nodeCount).toBeGreaterThanOrEqual(3);
});
it('should include display output', async () => {
const result = await handleGetNodeTree({
projectPath,
scenePath: 'scenes/main.tscn',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{ display: string }>(result);
expect(data.display).toContain('Root');
expect(data.display).toContain('Player');
});
it('should show script information', async () => {
const result = await handleGetNodeTree({
projectPath,
scenePath: 'scenes/with_script.tscn',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{
tree: { hasScript: boolean };
}>(result);
expect(data.tree.hasScript).toBe(true);
});
});
});
// ==========================================================================
// ListScenes Tests (from existing tests)
// ==========================================================================
describe('ListScenes', () => {
describe('validation', () => {
it('should return error when projectPath is missing', async () => {
const result = await handleListScenes({});
expect(isErrorResponse(result)).toBe(true);
});
it('should return error for non-existent project', async () => {
const result = await handleListScenes({
projectPath: '/non/existent/path',
});
expect(isErrorResponse(result)).toBe(true);
});
});
describe('listing operations', () => {
it('should list all scenes in project', async () => {
const result = await handleListScenes({
projectPath,
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{
count: number;
scenes: Array<{ path: string; name: string; type: string }>;
}>(result);
expect(data.count).toBeGreaterThanOrEqual(3);
expect(data.scenes.some((s) => s.path.includes('main.tscn'))).toBe(true);
});
it('should filter by directory', async () => {
const result = await handleListScenes({
projectPath,
directory: 'scenes',
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{
scenes: Array<{ path: string }>;
}>(result);
expect(data.scenes.every((s) => s.path.startsWith('scenes/'))).toBe(true);
});
it('should include scene type', async () => {
const result = await handleListScenes({
projectPath,
});
expect(isErrorResponse(result)).toBe(false);
const data = parseJsonResponse<{
scenes: Array<{ type: 'tscn' | 'scn' }>;
}>(result);
expect(data.scenes.every((s) => s.type === 'tscn' || s.type === 'scn')).toBe(true);
});
});
});
// ==========================================================================
// Cross-Tool Integration Tests
// ==========================================================================
describe('Cross-Tool Integration', () => {
it('should validate parameters for create scene operation', async () => {
// Create new scene - requires Godot to be available for full execution
const createResult = await handleCreateScene({
projectPath,
scenePath: 'scenes/integration_test.tscn',
rootNodeType: 'Node2D',
});
// Validation should always pass with correct parameters
if (isErrorResponse(createResult)) {
// If error, it should NOT be a validation error
expect(getResponseText(createResult)).not.toContain('Validation failed');
// It may be a Godot execution error which is acceptable in test env
} else {
// If success, the scene was created
expect(getResponseText(createResult)).toContain('created');
}
});
it('should duplicate node then manage its groups', async () => {
// First duplicate a node
const dupResult = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Player2',
});
expect(isErrorResponse(dupResult)).toBe(false);
// Then add groups to the duplicated node
const groupResult = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player2',
action: 'add',
groups: ['players', 'team_blue'],
});
expect(isErrorResponse(groupResult)).toBe(false);
// Verify the final state
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain('Player2');
expect(content).toContain('players');
});
it('should instance scene then list all scenes', async () => {
// Instance a scene
const instanceResult = await handleInstanceScene({
projectPath,
scenePath: 'scenes/main.tscn',
instancePath: 'scenes/player.tscn',
instanceName: 'PlayerInstance',
});
expect(isErrorResponse(instanceResult)).toBe(false);
// List all scenes
const listResult = await handleListScenes({
projectPath,
});
expect(isErrorResponse(listResult)).toBe(false);
const data = parseJsonResponse<{ scenes: Array<{ path: string }> }>(listResult);
expect(data.scenes.some(s => s.path.includes('player.tscn'))).toBe(true);
});
});
// ==========================================================================
// Boundary Condition Tests
// ==========================================================================
describe('Boundary Conditions', () => {
it('should handle very long node names', async () => {
const longName = 'A'.repeat(100);
const result = await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: longName,
});
// Should succeed or fail gracefully
if (!isErrorResponse(result)) {
const content = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(content).toContain(longName);
}
});
it('should handle special characters in group names', async () => {
const result = await handleManageGroups({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
action: 'add',
groups: ['group_with_underscore', 'group123'],
});
expect(isErrorResponse(result)).toBe(false);
});
it('should handle deeply nested node paths', async () => {
// Create nested structure first by duplicating
await handleDuplicateNode({
projectPath,
scenePath: 'scenes/player.tscn',
nodePath: 'Sprite',
newName: 'DeepSprite',
});
// Try to access via path
const result = await handleGetNodeTree({
projectPath,
scenePath: 'scenes/player.tscn',
});
expect(isErrorResponse(result)).toBe(false);
});
});
// ==========================================================================
// Error Recovery Tests
// ==========================================================================
describe('Error Recovery', () => {
it('should not corrupt scene file on validation error', async () => {
const originalContent = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
// Attempt operation that will fail validation
await handleAddNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodeType: '',
nodeName: '',
});
// File should be unchanged
const afterContent = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(afterContent).toBe(originalContent);
});
it('should not corrupt scene file when node not found', async () => {
const originalContent = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
// Attempt to operate on non-existent node
await handleDuplicateNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'NonExistentNode',
});
// File should be unchanged
const afterContent = readFileSync(join(projectPath, 'scenes/main.tscn'), 'utf-8');
expect(afterContent).toBe(originalContent);
});
});
});