/**
* RenameNodeTool Unit Tests
* ISO/IEC 29119 compliant - covers uncovered lines:
* 49, 54, 59, 99, 117-118, 124-139, 155-156
*
* These tests exercise:
* - Project validation error return (line 49)
* - Scene validation error return (line 54)
* - Empty new name validation (line 59)
* - Sibling name collision (line 99)
* - Nested parent path updates (lines 117-118)
* - Connection from/to name updates (lines 124-139)
* - Error handling catch block (lines 155-156)
*/
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import {
createTempProject,
getResponseText,
isErrorResponse,
} from '../test-utils.js';
import { handleRenameNode } from './RenameNodeTool.js';
describe('RenameNodeTool', () => {
let projectPath: string;
let cleanup: () => void;
beforeEach(() => {
const temp = createTempProject();
projectPath = temp.projectPath;
cleanup = temp.cleanup;
});
afterEach(() => {
cleanup();
});
describe('Validation', () => {
it('should reject missing projectPath', async () => {
const result = await handleRenameNode({
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing scenePath', async () => {
const result = await handleRenameNode({
projectPath,
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing nodePath', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject missing newName', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('Validation failed');
});
it('should reject invalid project path (line 49)', async () => {
const result = await handleRenameNode({
projectPath: '/non/existent/project',
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
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 path (line 54)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/nonexistent.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|does not exist/i);
});
it('should reject empty new name (line 59)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: ' ',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/empty|Validation failed/i);
});
it('should reject new name with invalid character / (line 65)', 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');
});
it('should reject new name with invalid character \\ (line 65)', 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');
});
it('should reject new name with invalid character : (line 65)', 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');
});
it('should reject new name with invalid character * (line 65)', 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');
});
it('should reject new name with invalid character ? (line 65)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Name?',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
it('should reject new name with invalid character " (line 65)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Name"Bad',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
it('should reject new name with invalid character < (line 65)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Name<Bad',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
it('should reject new name with invalid character > (line 65)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Name>Bad',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
it('should reject new name with invalid character | (line 65)', async () => {
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Name|Bad',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('invalid characters');
});
});
describe('Security', () => {
it('should reject path traversal in projectPath', async () => {
const result = await handleRenameNode({
projectPath: '../../../etc',
scenePath: 'scenes/main.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
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 handleRenameNode({
projectPath,
scenePath: '../../etc/passwd',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/path traversal|Validation failed/i);
});
});
describe('Happy Path', () => {
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');
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');
});
it('should reject rename if sibling with same name exists (line 99)', async () => {
// Main scene has "Player" and "Sprite" as siblings under Root
// Try to rename Player to Sprite (which is a child of Player, so actually
// we need a scene with two siblings at same level)
const sceneWithSiblings = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Player" type="CharacterBody2D" parent="."]
[node name="Enemy" type="CharacterBody2D" parent="."]
`;
writeFileSync(join(projectPath, 'scenes', 'siblings.tscn'), sceneWithSiblings);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/siblings.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toContain('already exists');
});
it('should update child node parent references (lines 111-114)', async () => {
// Scene where Player has children referencing it as parent
const sceneWithChildren = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Player" type="CharacterBody2D" parent="."]
[node name="Sprite" type="Sprite2D" parent="Player"]
[node name="CollisionShape" type="CollisionShape2D" parent="Player"]
`;
writeFileSync(join(projectPath, 'scenes', 'children.tscn'), sceneWithChildren);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/children.tscn',
nodePath: 'Player',
newName: 'Hero',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('renamed successfully');
expect(getResponseText(result)).toContain('Hero');
const content = readFileSync(join(projectPath, 'scenes/children.tscn'), 'utf-8');
expect(content).toContain('name="Hero"');
expect(content).toContain('parent="Hero"');
expect(content).not.toContain('parent="Player"');
});
it('should update nested parent paths (lines 117-118)', async () => {
// Scene with nested parent paths like "Player/SubNode"
const sceneWithNested = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Player" type="CharacterBody2D" parent="."]
[node name="Body" type="Node2D" parent="Player"]
[node name="Arm" type="Node2D" parent="Player/Body"]
`;
writeFileSync(join(projectPath, 'scenes', 'nested.tscn'), sceneWithNested);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/nested.tscn',
nodePath: 'Player',
newName: 'Character',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('renamed successfully');
const content = readFileSync(join(projectPath, 'scenes/nested.tscn'), 'utf-8');
expect(content).toContain('parent="Character"');
expect(content).toContain('parent="Character/Body"');
expect(content).not.toContain('parent="Player"');
expect(content).not.toContain('parent="Player/Body"');
});
it('should update connection from references (lines 124-131)', async () => {
// Scene with connections referencing the node being renamed
const sceneWithConnections = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Button" type="Button" parent="."]
[node name="Timer" type="Timer" parent="."]
[connection signal="pressed" from="Button" to="." method="_on_button_pressed"]
[connection signal="timeout" from="Timer" to="Button" method="_on_timer_timeout"]
`;
writeFileSync(join(projectPath, 'scenes', 'connections.tscn'), sceneWithConnections);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/connections.tscn',
nodePath: 'Button',
newName: 'ActionButton',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('renamed successfully');
const content = readFileSync(join(projectPath, 'scenes/connections.tscn'), 'utf-8');
expect(content).toContain('from="ActionButton"');
expect(content).toContain('to="ActionButton"');
expect(content).not.toContain('from="Button"');
});
it('should update connection path references (lines 133-140)', async () => {
// Scene with connections using path-style references
const sceneWithPathConnections = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="UI" type="Control" parent="."]
[node name="Btn" type="Button" parent="UI"]
[connection signal="pressed" from="UI/Btn" to="UI/Btn" method="_on_pressed"]
`;
writeFileSync(join(projectPath, 'scenes', 'path_conns.tscn'), sceneWithPathConnections);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/path_conns.tscn',
nodePath: 'UI',
newName: 'Interface',
});
expect(isErrorResponse(result)).toBe(false);
const content = readFileSync(join(projectPath, 'scenes/path_conns.tscn'), 'utf-8');
expect(content).toContain('from="Interface/Btn"');
expect(content).toContain('to="Interface/Btn"');
});
it('should report number of updated references', async () => {
const sceneWithRefs = `[gd_scene format=3]
[node name="Root" type="Node2D"]
[node name="Player" type="CharacterBody2D" parent="."]
[node name="Sprite" type="Sprite2D" parent="Player"]
[connection signal="ready" from="Player" to="." method="_on_player_ready"]
`;
writeFileSync(join(projectPath, 'scenes', 'refs.tscn'), sceneWithRefs);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/refs.tscn',
nodePath: 'Player',
newName: 'Hero',
});
expect(isErrorResponse(result)).toBe(false);
expect(getResponseText(result)).toContain('References updated:');
});
});
describe('Error Handling', () => {
it('should handle file read errors (lines 155-156)', async () => {
// Use a scene path that passes validation but points to a malformed file
const malformedScene = 'NOT A VALID TSCN FILE {{{{';
writeFileSync(join(projectPath, 'scenes', 'malformed.tscn'), malformedScene);
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/malformed.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
// Should either fail gracefully with error response or succeed parsing
// The important thing is no unhandled exception
if (isErrorResponse(result)) {
expect(getResponseText(result)).toMatch(/Failed to rename|not found/i);
}
});
it('should handle error when scene file cannot be read (lines 155-156)', async () => {
// Write a scene file, then make the directory it points to not readable
// Instead, let's reference a nonexistent file that passes path validation
// but fails at readFileSync
mkdirSync(join(projectPath, 'scenes', 'subdir'), { recursive: true });
writeFileSync(join(projectPath, 'scenes', 'subdir', 'test.tscn'), '');
// This will cause parseTscn to potentially fail on empty content
const result = await handleRenameNode({
projectPath,
scenePath: 'scenes/subdir/test.tscn',
nodePath: 'Player',
newName: 'Enemy',
});
// The node won't be found in an empty scene
expect(isErrorResponse(result)).toBe(true);
expect(getResponseText(result)).toMatch(/not found|Failed to rename/i);
});
});
});