// Headless test: verify custom function blocks generate JS correctly. const Blockly = require('blockly'); const { javascriptGenerator, Order } = require('blockly/javascript'); const FN_COLOR = '#E91E63'; const OVAL_COLOR = '#FF8C1A'; function escXml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // --- Simulated app blocks (statement blocks return strings; value blocks tuples) --- javascriptGenerator.forBlock['rb_js_set_var'] = function (block) { const name = block.getFieldValue('name') || 'myVar'; let value = ''; const t = block.getInputTargetBlock('value'); if (t) { const tuple = javascriptGenerator.blockToCode(t, Order.ATOMIC); value = Array.isArray(tuple) ? tuple[0] : tuple || ''; } const code = `myVar${''} = ${value || 'undefined'};`; return block.outputConnection ? [code, Order.ATOMIC] : code; // statement -> string }; javascriptGenerator.forBlock['rb_js_const_number'] = function (block) { return [String(block.getFieldValue('value') ?? 0), Order.ATOMIC]; }; javascriptGenerator.forBlock['rb_js_console'] = function (block) { let value = ''; const t = block.getInputTargetBlock('value'); if (t) { const tuple = javascriptGenerator.blockToCode(t, Order.ATOMIC); value = Array.isArray(tuple) ? tuple[0] : tuple || ''; } const code = `console.log(${value || "''"});`; return block.outputConnection ? [code, Order.ATOMIC] : code; }; // --- Minimal app block definitions --- Blockly.Blocks['rb_js_set_var'] = { init() { this.appendDummyInput().appendField('set var').appendField('myVar', 'name'); this.appendValueInput('value'); this.setPreviousStatement(true); this.setNextStatement(true); }, }; Blockly.Blocks['rb_js_const_number'] = { init() { this.appendDummyInput().appendField('value').appendField(new Blockly.FieldNumber(0), 'value'); this.setOutput(true); }, }; Blockly.Blocks['rb_js_console'] = { init() { this.appendDummyInput().appendField('print'); this.appendValueInput('value'); this.setPreviousStatement(true); this.setNextStatement(true); }, }; // --- Register the custom function block types (mirrors customFunctionBlocks.ts) --- function parseFnData(block) { try { const d = JSON.parse(String(block.data || '{}')); if (d && typeof d === 'object' && typeof d.name === 'string') return d; } catch {} return null; } function paramSegsOf(segments) { return (segments || []).filter((s) => s.kind === 'param'); } function encodeName(name) { return String(name).replace(/%/g, '\\u0025').replace(/\s+/g, '_').replace(/\W/g, (ch) => { return '%' + ch.codePointAt(0).toString(16).toUpperCase(); }); } function fnParamNames(data) { return paramSegsOf(data.segments).map((s) => encodeName(s.name)); } Blockly.Blocks['rb_custom_fn'] = { init() { this.setColour(FN_COLOR); this.appendDummyInput(); this.setInputsInline(true); }, domToMutation(m) { try { const data = { name: m.getAttribute('name') || '', segments: JSON.parse(m.getAttribute('segments') || '[]'), asyncFn: m.getAttribute('async') === 'true', returns: m.getAttribute('returns') === 'true', }; this.data = JSON.stringify(data); // rebuild layout: define + param value inputs + body statement for (const input of [...this.inputList]) this.removeInput(input.name); const row = this.appendDummyInput(); row.appendField('define'); row.appendField(data.name, 'name'); let pendingText = ''; let idx = 0; for (const seg of data.segments) { if (seg.kind === 'text') pendingText += seg.text; else { const vi = this.appendValueInput('PARAM' + idx); if (pendingText) vi.appendField(pendingText); pendingText = ''; if (seg.type === 'boolean') vi.setCheck('Boolean'); idx++; } } const props = this.appendDummyInput(); if (pendingText) props.appendField(pendingText); props.appendField(data.asyncFn ? 'TRUE' : 'FALSE', 'asyncFn'); props.appendField('async'); props.appendField(data.returns ? 'TRUE' : 'FALSE', 'returns'); props.appendField('return value'); this.appendStatementInput('body'); this.setInputsInline(true); } catch (e) { throw e; } }, mutationToDom() { const m = Blockly.utils.xml.createElement('mutation'); const d = parseFnData(this) || { name: '', segments: [] }; m.setAttribute('name', String(d.name || '')); m.setAttribute('segments', JSON.stringify(d.segments || [])); m.setAttribute('async', d.asyncFn ? 'true' : 'false'); m.setAttribute('returns', d.returns ? 'true' : 'false'); return m; }, }; Blockly.Blocks['rb_custom_call'] = { init() { this.setColour(FN_COLOR); this.setPreviousStatement(true); this.setNextStatement(true); this.appendDummyInput(); this.setInputsInline(true); }, domToMutation(m) { const data = { name: m.getAttribute('name') || '', segments: JSON.parse(m.getAttribute('segments') || '[]'), returns: m.getAttribute('returns') === 'true', }; this.data = JSON.stringify(data); for (const input of [...this.inputList]) this.removeInput(input.name); const head = this.appendDummyInput(); head.appendField(data.name, 'fnName'); let pendingText = ''; let idx = 0; for (const seg of data.segments) { if (seg.kind === 'text') pendingText += seg.text; else { const vi = this.appendValueInput('PARAM' + idx); if (pendingText) vi.appendField(pendingText); pendingText = ''; if (seg.type === 'boolean') vi.setCheck('Boolean'); idx++; } } const trail = this.appendDummyInput(); if (pendingText) trail.appendField(pendingText); this.setInputsInline(true); }, mutationToDom() { const m = Blockly.utils.xml.createElement('mutation'); const d = parseFnData(this) || { name: '', segments: [] }; m.setAttribute('name', String(d.name || '')); m.setAttribute('segments', JSON.stringify(d.segments || [])); return m; }, }; Blockly.Blocks['rb_custom_param_var'] = { init() { this.setColour(OVAL_COLOR); this.setOutput(true); this.appendDummyInput().appendField('param', 'name'); this.setInputsInline(true); }, }; // generators use second param `gen` (mirrors the fix) javascriptGenerator.forBlock['rb_custom_fn'] = function (block, gen) { const data = parseFnData(block); const name = encodeName(block.getFieldValue('name') || (data && data.name) || 'myFunction'); const asyncFn = block.getFieldValue('asyncFn') === 'TRUE'; const params = (data ? fnParamNames(data) : []).join(', '); const body = gen.statementToCode(block, 'body'); return `${asyncFn ? 'async ' : ''}function ${name}(${params}) {\n${body}\n}`; }; function callCode(block, gen, isValue) { const data = parseFnData(block); const name = encodeName((data && data.name) || 'myFunction'); const params = paramSegsOf((data && data.segments) || []); const args = params .map((_, i) => { try { return gen.valueToCode(block, 'PARAM' + i, Order.ATOMIC) || 'undefined'; } catch { return 'undefined'; } }) .join(', '); return isValue ? `${name}(${args})` : `${name}(${args});`; } javascriptGenerator.forBlock['rb_custom_call'] = function (block, gen) { return callCode(block, gen, false); }; javascriptGenerator.forBlock['rb_custom_call_value'] = function (block, gen) { return [callCode(block, gen, true), Order.ATOMIC]; }; javascriptGenerator.forBlock['rb_custom_param_var'] = function (block) { return [encodeName(String(block.getFieldValue('name') || '')), Order.ATOMIC]; }; // --- Build a workspace with a function hat containing body blocks + a caller --- const xml = ` greet FALSE FALSE who greet who greet 42 `; const ws = new Blockly.Workspace(); Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(xml), ws); let ok = true; function check(label, actual, expected) { const pass = actual === expected; if (!pass) ok = false; console.log((pass ? 'PASS' : 'FAIL') + ' ' + label); if (!pass) { console.log(' expected: ' + JSON.stringify(expected)); console.log(' actual: ' + JSON.stringify(actual)); } } // Generate from the whole workspace javascriptGenerator.init(ws); const whole = javascriptGenerator.workspaceToCode(ws); const expectedWhole = `function greet(who) { console.log(who); } greet(42);`; check('workspaceToCode', whole, expectedWhole); // Generate the fn hat alone via blockToCode (as generateCodeFromBlock does) const fnBlock = ws.getTopBlocks(true).find((b) => b.type === 'rb_custom_fn'); const fnTuple = javascriptGenerator.blockToCode(fnBlock); check('fn blockToCode is string', typeof fnTuple === 'string', true); check('fn blockToCode', fnTuple, `function greet(who) { console.log(who); }`); // Caller alone const callBlock = ws.getTopBlocks(true).find((b) => b.type === 'rb_custom_call'); const callStr = javascriptGenerator.blockToCode(callBlock); check('call blockToCode is string', typeof callStr === 'string', true); check('call blockToCode', callStr, 'greet(42);'); // Caller with an empty param socket must not emit `greet(, )` (syntax error); // the empty arg falls back to `undefined`. const emptyXml = `` + `` + ``; const ws2 = new Blockly.Workspace(); Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(emptyXml), ws2); javascriptGenerator.init(ws2); const emptyCall = javascriptGenerator.workspaceToCode(ws2); check('empty-arg caller -> undefined', emptyCall, 'greet(undefined);'); process.exit(ok ? 0 : 1);