Files

451 lines
15 KiB
JavaScript

// Integration Test: opencode GDevelop plugin tools (T09, issue #17).
//
// Imports the real packages/opencode-gdevelop/ plugin (index.mjs +
// tools.mjs, which delegate every project mutation to
// packages/unlock-shim/control-shim.mjs — no reimplemented mutation logic)
// and drives it through an in-process ctx double whose tool editor is a real
// registry (Map-backed add/list/get) and whose tool executors perform REAL
// file mutations on scratch game.json projects under os.tmpdir().
// No mocks of the code under test; the only double is the host ctx object
// itself (the @opencode/plugin host is not running in this repo).
//
// Bare node:test + builtins only. No skipped tests.
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pluginDefault, {
definition as pluginDefinition,
} from '../../packages/opencode-gdevelop/index.mjs';
import {
GDEVELOP_TOOLS,
TOOL_NAMES,
} from '../../packages/opencode-gdevelop/tools.mjs';
import { readGDevelopProject } from '../../packages/unlock-shim/control-shim.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..', '..');
// ---------------------------------------------------------------------------
// In-process host doubles (real registry behavior, real executor dispatch).
// ---------------------------------------------------------------------------
const createToolEditorDouble = () => {
const tools = new Map();
return {
list: () => [...tools.values()],
get: id => tools.get(id),
namespace: () => {},
add: tool => {
assert.ok(tool && typeof tool.name === 'string', 'tool needs a name');
assert.equal(typeof tool.execute, 'function', `${tool.name} needs execute`);
tools.set(tool.name, { ...tool, id: tool.name });
},
update: (id, fn) => {
const current = tools.get(id);
if (current) fn(current);
},
remove: id => {
tools.delete(id);
},
};
};
const createCtxDouble = () => {
const editor = createToolEditorDouble();
const agents = new Map();
return {
editor,
tool: {
transform: async fn => {
fn(editor);
return { dispose: async () => {} };
},
reload: async () => {},
list: async () => editor.list(),
},
agent: {
transform: async fn => {
fn({
list: () => [...agents.values()],
get: id => agents.get(id),
default: () => {},
update: (id, fn2) => {
const current = agents.get(id) || { id };
agents.set(id, current);
fn2(current);
current.id = id;
},
remove: id => {
agents.delete(id);
},
});
return { dispose: async () => {} };
},
},
};
};
// ---------------------------------------------------------------------------
// Scratch projects (real I/O under os.tmpdir(), never /tmp hardcoded).
// ---------------------------------------------------------------------------
let scratchDirs = [];
const mkScratch = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gdevelop-plugin-'));
scratchDirs.push(dir);
return dir;
};
afterEach(() => {
for (const dir of scratchDirs) fs.rmSync(dir, { recursive: true, force: true });
scratchDirs = [];
});
const minimalProjectFile = (dir, name = 'PluginGame') => {
const file = path.join(dir, 'game.json');
fs.writeFileSync(
file,
JSON.stringify(
{ properties: { name, version: '1.0.0' }, layouts: [], objects: [] },
null,
2
) + '\n',
'utf8'
);
return file;
};
const runTool = async (editor, name, input) => {
const tool = editor.get(name);
assert.ok(tool, `tool registered: ${name}`);
return tool.execute(input, {
sessionID: 'sess-test',
agent: 'build',
messageID: 'msg-1',
id: 'call-1',
signal: AbortSignal.timeout(10000),
progress: async () => {},
});
};
describe('integration: gdevelop plugin registration (real setup)', () => {
it('exports Plugin.define({id:"gdevelop",setup}) via the host API shape', async () => {
const plugin = pluginDefault || pluginDefinition;
assert.equal(plugin.id, 'gdevelop');
assert.equal(typeof plugin.setup, 'function');
assert.deepEqual(pluginDefinition.id, 'gdevelop');
});
it('setup registers all six gdevelop_* tools via ctx.tool.transform', async () => {
const ctx = createCtxDouble();
let transformCalls = 0;
const counting = {
...ctx,
tool: {
...ctx.tool,
transform: async fn => {
transformCalls += 1;
return ctx.tool.transform(fn);
},
},
};
await pluginDefinition.setup(counting);
assert.equal(transformCalls, 1, 'exactly one ctx.tool.transform call');
const names = ctx.editor.list().map(t => t.name).sort();
assert.deepEqual(names, [...TOOL_NAMES].sort());
assert.deepEqual(GDEVELOP_TOOLS.map(t => t.name).sort(), [...TOOL_NAMES].sort());
for (const tool of ctx.editor.list()) {
assert.ok(tool.description.length > 0, `${tool.name} has a description`);
assert.ok(tool.input && typeof tool.input === 'object', `${tool.name} has an input schema`);
}
});
it('tool input schemas are plain JSON-schema objects (zero-dep host compatible)', () => {
for (const tool of GDEVELOP_TOOLS) {
assert.equal(tool.input.type, 'object', `${tool.name} input is an object schema`);
assert.ok(tool.input.properties && typeof tool.input.properties === 'object');
}
});
});
describe('integration: gdevelop plugin tools mutate real projects', () => {
let ctx;
beforeEach(async () => {
ctx = createCtxDouble();
await pluginDefinition.setup(ctx);
});
it('create_scene → create_object → add_behavior → add_events round-trip on disk', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
const sceneRes = await runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'Level1',
});
assert.ok(sceneRes.output.created);
assert.equal(sceneRes.output.sceneName, 'Level1');
const objRes = await runTool(ctx.editor, 'gdevelop_project_create_object', {
projectPath,
sceneName: 'Level1',
objectName: 'Hero',
objectType: 'Sprite',
});
assert.ok(objRes.output.created);
const behRes = await runTool(ctx.editor, 'gdevelop_project_add_behavior', {
projectPath,
sceneName: 'Level1',
objectName: 'Hero',
behaviorName: 'Platformer',
behaviorType: 'PlatformBehavior::PlatformerObjectBehavior',
});
assert.equal(behRes.output.behaviorName, 'Platformer');
const evtRes = await runTool(ctx.editor, 'gdevelop_project_add_events', {
projectPath,
sceneName: 'Level1',
conditions: [],
actions: [],
});
assert.equal(evtRes.output.eventType, 'BuiltinCommonInstructions::Standard');
// Ground truth re-read from disk (not via tool output).
const onDisk = readGDevelopProject(projectPath);
const scene = onDisk.layouts.find(l => l.name === 'Level1');
assert.ok(scene, 'scene persisted');
const hero = scene.objects.find(o => o.name === 'Hero');
assert.equal(hero.type, 'Sprite');
assert.ok(hero.behaviors.some(b => b.name === 'Platformer'));
assert.equal(scene.events.length, 1);
});
it('project_read inspects scenes/objects/behaviors/variables from disk', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
await runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'Menu',
});
await runTool(ctx.editor, 'gdevelop_project_create_object', {
projectPath,
sceneName: 'Menu',
objectName: 'Title',
objectType: 'TextObject::Text',
});
const res = await runTool(ctx.editor, 'gdevelop_project_read', { projectPath });
assert.equal(res.output.projectName, 'PluginGame');
const menu = res.output.scenes.find(s => s.name === 'Menu');
assert.ok(menu, 'Menu scene reported');
assert.deepEqual(menu.objects, ['Title']);
assert.ok(typeof res.content === 'string' && res.content.includes('Menu'));
});
it('create_object supports Text/PanelSprite + 3D types', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
await runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'Stage',
});
for (const [objectName, objectType] of [
['Label', 'TextObject::Text'],
['Panel', 'PanelSpriteObject::PanelSprite'],
['Cube', 'Scene3D::Box3DObject'],
]) {
const res = await runTool(ctx.editor, 'gdevelop_project_create_object', {
projectPath,
sceneName: 'Stage',
objectName,
objectType,
});
assert.ok(res.output.created, `${objectType} created`);
}
const onDisk = readGDevelopProject(projectPath);
const stage = onDisk.layouts.find(l => l.name === 'Stage');
assert.deepEqual(
stage.objects.map(o => o.type).sort(),
['PanelSpriteObject::PanelSprite', 'Scene3D::Box3DObject', 'TextObject::Text'].sort()
);
});
it('cli_run formats a headless dispatch via buildGDevelopCliCommand', async () => {
const res = await runTool(ctx.editor, 'gdevelop_cli_run', {
gdevelopPath: '/opt/gdevelop/gdevelop',
projectPath: '/games/demo/game.json',
commandName: 'EXPORT_ANDROID',
cmdArgs: ['--debug'],
headless: true,
});
assert.equal(res.output.command, '/opt/gdevelop/gdevelop');
assert.ok(res.output.args.includes('--run-command'));
assert.ok(res.output.args.includes('EXPORT_ANDROID'));
assert.ok(res.output.args.includes('--disable-update-check'));
assert.ok(res.output.args.includes('/games/demo/game.json'));
});
});
describe('integration: gdevelop plugin tools fail closed (adversarial)', () => {
let ctx;
beforeEach(async () => {
ctx = createCtxDouble();
await pluginDefinition.setup(ctx);
});
it('missing project files reject with the path named', async () => {
const dir = mkScratch();
const missing = path.join(dir, 'nope.json');
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_read', { projectPath: missing }),
/not found|ENOENT/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath: missing,
sceneName: 'X',
}),
/not found|ENOENT/i
);
});
it('blank scene/object names reject', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: ' ',
}),
/sceneName|non-empty/i
);
await runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'S1',
});
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_create_object', {
projectPath,
sceneName: 'S1',
objectName: '',
objectType: 'Sprite',
}),
/objectName|non-empty/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'S1',
}),
/already exists/i
);
});
it('unknown scene for object/behavior/event writes rejects', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_create_object', {
projectPath,
sceneName: 'Ghost',
objectName: 'O',
objectType: 'Sprite',
}),
/scene not found/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_add_behavior', {
projectPath,
sceneName: 'Ghost',
objectName: 'O',
behaviorName: 'B',
behaviorType: 'PlatformBehavior::PlatformerObjectBehavior',
}),
/scene not found|object not found/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_add_events', {
projectPath,
sceneName: 'Ghost',
}),
/scene not found/i
);
});
it('malformed events (non-array conditions/actions) reject', async () => {
const dir = mkScratch();
const projectPath = minimalProjectFile(dir);
await runTool(ctx.editor, 'gdevelop_project_create_scene', {
projectPath,
sceneName: 'S1',
});
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_add_events', {
projectPath,
sceneName: 'S1',
conditions: 'always',
}),
/conditions must be an array/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_project_add_events', {
projectPath,
sceneName: 'S1',
actions: { do: 'x' },
}),
/actions must be an array/i
);
});
it('CLI dispatch with bad command names/paths rejects', async () => {
await assert.rejects(
runTool(ctx.editor, 'gdevelop_cli_run', {
gdevelopPath: '/opt/gdevelop/gdevelop',
projectPath: '/games/demo/game.json',
commandName: 'export-android',
}),
/UPPER_SNAKE_CASE/i
);
await assert.rejects(
runTool(ctx.editor, 'gdevelop_cli_run', {
gdevelopPath: '',
projectPath: '/games/demo/game.json',
commandName: 'EXPORT_ANDROID',
}),
/gdevelopPath is required/i
);
});
});
describe('integration: gdevelop plugin packaging (zero-dep, zero-egress)', () => {
it('package name uses the @heretek-ai scope and declares zero deps', () => {
const pkg = JSON.parse(
fs.readFileSync(path.join(root, 'packages/opencode-gdevelop/package.json'), 'utf8')
);
assert.equal(pkg.name, '@heretek-ai/opencode-gdevelop');
assert.ok(!pkg.dependencies, 'no runtime dependencies allowed');
assert.ok(!pkg.devDependencies, 'no devDependencies allowed');
assert.ok(!pkg.name.startsWith('@gdevelop'), 'never the upstream-owned scope');
});
it('plugin sources never touch the hosted API and import the control shim', () => {
for (const file of ['index.mjs', 'tools.mjs']) {
const src = fs.readFileSync(
path.join(root, 'packages/opencode-gdevelop', file),
'utf8'
);
assert.ok(!src.includes('api.gdevelop.io'), `${file}: zero egress`);
}
const toolsSrc = fs.readFileSync(
path.join(root, 'packages/opencode-gdevelop/tools.mjs'),
'utf8'
);
assert.match(toolsSrc, /control-shim\.mjs/, 'tools delegate to the control shim');
assert.ok(!toolsSrc.includes('writeFileSync'), 'no reimplemented project mutation');
});
});