From fb014066d6caa9665bf539c8cc008e14335df2d0 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Tue, 9 Jun 2026 18:25:09 -0400 Subject: [PATCH] fix(compile): unbreak Runtime v3 build; warn on unsupported blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 upload branch sat AFTER installArduinoCore in the shared pipeline, so every v3 build ran strucpp (fine), then tried to install an Arduino core with an empty FQBN ("invalid empty core argument") and aborted before the program.st upload was reached. v3 has nothing to do with arduino-cli — its on-device MatIEC recompiles the uploaded program.st. - Move the isRuntimeV3 branch above installArduinoCore (right after the strucpp compile / v4 branch). Strucpp still runs first purely as a user-code error check; a strucpp error bails before upload. - C/C++ and Python function blocks are NOT compatible with v3: both lower to strucpp `{external ...}` inline-C that v3's MatIEC toolchain can't parse (it bails with a wall of ST syntax errors). Rather than a hard block, warn on board switch — the same soft prompt Arduino already shows for Python — and let the user proceed (the on-device compile fails if they do). board.tsx now warns for Python and/or C/C++ blocks on v3 via a dynamic label reusing the existing modal. - New pipeline-runtime-v3.test.ts (the existing pipeline.test.ts is stale from the xml2st->JSON-transpiler migration and references the removed transpileXmlToSt port method). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/pipeline-runtime-v3.test.ts | 171 ++++++++++++++++++ src/backend/shared/compile/pipeline.ts | 82 +++++---- .../editor/device/configuration/board.tsx | 34 +++- 3 files changed, 246 insertions(+), 41 deletions(-) create mode 100644 src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts diff --git a/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts b/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts new file mode 100644 index 000000000..e06bb12a8 --- /dev/null +++ b/src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts @@ -0,0 +1,171 @@ +/** + * Focused tests for the Runtime v3 branch of the shared compile pipeline. + * + * v3 is the legacy target that ingests a single `program.st` (not a v4 + * zip) and recompiles it on-device with MatIEC. The pipeline must + * therefore short-circuit to `uploadRuntimeV3` BEFORE any arduino-cli + * step (core/lib install, firmware bundle, compile) — a regression + * where the v3 branch sat after `installArduinoCore` made every v3 + * build die with "invalid empty core argument" (v3 has no Arduino + * core). These tests lock that ordering in. + * + * Kept in a separate file from `pipeline.test.ts` (which is stale from + * the xml2st→JSON-transpiler migration and references the removed + * `transpileXmlToSt` port method) so the v3 coverage compiles + runs + * against the current `transpileToSt` contract. + */ + +import type { DevicePin } from '../../types/PLC/devices' +import type { PLCProjectData } from '../../types/PLC/open-plc' +import type { + CompilerPlatformPort, + PlatformDeviceContext, +} from '../../../../middleware/shared/ports/compiler-platform-port' + +jest.mock('../../utils/PLC/xml-generator', () => ({ XmlGenerator: jest.fn() })) +jest.mock('../../library/program-build-pipeline', () => ({ runProgramBuildPipeline: jest.fn() })) +jest.mock('../../library/program-build-helpers', () => ({ + buildKnownPous: jest.fn(() => []), + emitCompileErrorEvents: jest.fn(), +})) +jest.mock('../../firmware/build-arduino-cli-args', () => ({ + buildArduinoCliCompileArgs: jest.fn(() => ['compile', '-b', 'arduino:avr:mega']), +})) +jest.mock('../../firmware/runtime-version-gate', () => ({ + isStrucppCompatibleRuntime: jest.fn(() => true), + describeIncompatibleRuntime: jest.fn((v: string | null) => `Runtime ${String(v)} too old`), +})) +jest.mock('../steps/generate-confs', () => ({ + generateRuntimeConfs: jest.fn(() => ({ modbusSlave: '', modbusMaster: '', s7Comm: '', opcUa: null, ethercat: '' })), +})) + +import { runProgramBuildPipeline } from '../../library/program-build-pipeline' +import { type PipelineProgressEvent, runCompilePipeline, type RunCompilePipelineArgs } from '../pipeline' + +const mockedStrucpp = runProgramBuildPipeline as jest.MockedFunction + +function makePort(overrides: Partial = {}): jest.Mocked { + return { + computeMd5: jest.fn().mockResolvedValue('a'.repeat(32)), + transpileToSt: jest.fn().mockResolvedValue({ ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }), + installArduinoCore: jest.fn().mockResolvedValue({ ok: true }), + installArduinoLib: jest.fn().mockResolvedValue({ ok: true }), + compileArduino: jest.fn().mockResolvedValue({ ok: true, binary: new Uint8Array([1]) }), + uploadRuntimeV4: jest.fn().mockResolvedValue({ ok: true }), + uploadArduinoBoard: jest.fn().mockResolvedValue({ ok: true }), + uploadRuntimeV3: jest.fn().mockResolvedValue({ ok: true }), + checkRuntimeVersion: jest.fn().mockResolvedValue({ ok: true, version: '3.0' }), + packageVppPlugin: jest.fn().mockResolvedValue({ files: {} }), + ...overrides, + } as unknown as jest.Mocked +} + +const deviceContext: PlatformDeviceContext = { kind: 'editor-https', ip: '192.168.1.199', jwt: 'jwt' } + +function makeArgs(overrides: Partial = {}): RunCompilePipelineArgs { + return { + projectData: { + pous: [], + dataTypes: [], + configuration: { resource: { tasks: [], instances: [], globalVariables: [] } }, + servers: [], + remoteDevices: [], + } as unknown as PLCProjectData, + boardTarget: 'OpenPLC Runtime v3', + boardRuntime: 'openplc-runtime', + boardEntry: { platform: '', core: '', define: [] }, + devicePinMapping: [] as DevicePin[], + isSimulator: false, + isRuntimeV4: false, + isRuntimeV3: true, + compileOnly: false, + libraryArchives: [], + missingLibraries: [], + firmwareSkeleton: {}, + strucppRuntimeHeaders: {}, + avrLibStdCppInclude: '', + arduinoCliParallel: false, + deviceContext, + ...overrides, + } +} + +function captureEvents() { + const events: PipelineProgressEvent[] = [] + return { events, emit: (e: PipelineProgressEvent) => events.push(e) } +} + +beforeEach(() => { + jest.clearAllMocks() + mockedStrucpp.mockReturnValue({ + success: true, + files: [{ name: 'debug-map.json', content: '{}' }], + errors: [], + warnings: [], + md5Hash: 'a'.repeat(32), + splitterFallbackMessage: null, + debugMapSummary: null, + }) +}) + +describe('runCompilePipeline — Runtime v3 branch', () => { + it('uploads program.st via uploadRuntimeV3 WITHOUT touching any arduino-cli step', async () => { + const port = makePort() + const { events, emit } = captureEvents() + + const result = await runCompilePipeline(makeArgs(), port, emit) + + expect(result).toEqual({ success: true, md5: 'a'.repeat(32), uploaded: true }) + // The regression guard: v3 must never reach the Arduino path. + expect(port.installArduinoCore).not.toHaveBeenCalled() + expect(port.installArduinoLib).not.toHaveBeenCalled() + expect(port.compileArduino).not.toHaveBeenCalled() + expect(port.uploadRuntimeV4).not.toHaveBeenCalled() + // Strucpp still runs as an error-check before upload. + expect(mockedStrucpp).toHaveBeenCalledTimes(1) + expect(port.uploadRuntimeV3).toHaveBeenCalledTimes(1) + expect(events.map((e) => e.stage)).toContain('done') + }) + + it('passes the plain program.st (no FILE markers) when there are no C/C++ POUs', async () => { + const port = makePort() + const { emit } = captureEvents() + + await runCompilePipeline(makeArgs(), port, emit) + + const [uploadArg] = port.uploadRuntimeV3.mock.calls[0] + expect(uploadArg.programSt).toBe('PROGRAM main\nEND_PROGRAM') + expect(uploadArg.programSt).not.toContain('(*FILE:') + }) + + it('skips upload in compileOnly mode', async () => { + const port = makePort() + const { emit } = captureEvents() + + const result = await runCompilePipeline(makeArgs({ compileOnly: true }), port, emit) + + expect(result).toEqual({ success: true, md5: 'a'.repeat(32), uploaded: false }) + expect(port.uploadRuntimeV3).not.toHaveBeenCalled() + expect(port.installArduinoCore).not.toHaveBeenCalled() + }) + + it('warns and skips upload when no device context is configured', async () => { + const port = makePort() + const { events, emit } = captureEvents() + + const result = await runCompilePipeline(makeArgs({ deviceContext: undefined }), port, emit) + + expect(result.uploaded).toBe(false) + expect(port.uploadRuntimeV3).not.toHaveBeenCalled() + expect(events.some((e) => e.level === 'warning' && /v3 not configured/i.test(e.message))).toBe(true) + }) + + it('bails when uploadRuntimeV3 reports failure', async () => { + const port = makePort({ uploadRuntimeV3: jest.fn().mockResolvedValue({ ok: false }) }) + const { emit } = captureEvents() + + const result = await runCompilePipeline(makeArgs(), port, emit) + + expect(result.success).toBe(false) + }) +}) diff --git a/src/backend/shared/compile/pipeline.ts b/src/backend/shared/compile/pipeline.ts index c38ac949e..10548e971 100644 --- a/src/backend/shared/compile/pipeline.ts +++ b/src/backend/shared/compile/pipeline.ts @@ -591,7 +591,53 @@ async function runCompilePipelineInner( } // --------------------------------------------------------------------- - // Step 4b: Arduino / Simulator path — install core + lib (no-op on + // Step 4b: Runtime v3 branch — legacy target that ingests a single + // `program.st` (not a zip). v3's on-device MatIEC recompiles the ST + // itself, so this MUST short-circuit BEFORE the arduino-cli path + // (core/lib install, firmware bundle, compile) — none of which apply + // to v3. (Placing it after `installArduinoCore` was the bug: v3 has + // no Arduino core, so the install ran with an empty FQBN and aborted + // the build before the upload was ever reached.) + // + // Strucpp already ran above purely as a correctness check; a strucpp + // error bails before we get here, which is the desired behaviour + // (catch user code errors without an on-device round-trip). + // + // C/C++ and Python function blocks are NOT supported on v3 — they + // lower to strucpp `{external ...}` inline-C that v3's MatIEC can't + // parse — and are rejected up front by the editor compile adapter + // before this pipeline runs (see `createEditorCompilerAdapter`). So + // the ST that reaches here is plain IEC that MatIEC accepts; we just + // upload it verbatim. + // --------------------------------------------------------------------- + if (isRuntimeV3) { + if (compileOnly) { + emit({ stage: 'done', message: 'Compile only mode — skipping upload to runtime v3.', level: 'info' }) + return { success: true, md5, uploaded: false } + } + if (!deviceContext) { + emit({ + stage: 'upload', + message: 'Runtime v3 not configured. Skipping upload.', + level: 'warning', + }) + return { success: true, md5, uploaded: false } + } + + emit({ stage: 'upload', message: 'Uploading program.st to Runtime v3...', level: 'info' }) + const uploadResult = await port.uploadRuntimeV3( + { programSt, context: deviceContext }, + makePlatformLog(emit, 'upload'), + ) + if (!uploadResult.ok) { + return bailError(emit, 'upload', 'Failed to upload to Runtime v3.', uploadResult.errors) + } + emit({ stage: 'done', message: 'Runtime v3 upload complete.', level: 'info' }) + return { success: true, md5, uploaded: true } + } + + // --------------------------------------------------------------------- + // Step 4c: Arduino / Simulator path — install core + lib (no-op on // web), generate defines.h, compose firmware bundle, compile via // arduino-cli. // --------------------------------------------------------------------- @@ -677,40 +723,6 @@ async function runCompilePipelineInner( parallel: arduinoCliParallel, }) - // --------------------------------------------------------------------- - // Step 4b (cont.): Runtime v3 branch is a sub-case that runs BEFORE - // the arduino-cli compile — it embeds C blocks into program.st and - // uploads the merged ST file directly to the device. - // --------------------------------------------------------------------- - if (isRuntimeV3) { - if (compileOnly) { - emit({ stage: 'done', message: 'Compile only mode — skipping upload to runtime v3.', level: 'info' }) - return { success: true, md5, uploaded: false } - } - if (!deviceContext) { - emit({ - stage: 'upload', - message: 'Runtime v3 not configured. Skipping upload.', - level: 'warning', - }) - return { success: true, md5, uploaded: false } - } - emit({ stage: 'embed-c-blocks', message: 'Embedding C blocks into program.st...', level: 'info' }) - // Editor's port implementation embeds c_blocks.h + c_blocks_code.cpp - // into program.st as (*FILE:...*) marked comments; web's port - // implementation no-ops (web doesn't target v3). The pipeline - // delegates to the port so the embed details stay platform-specific. - const uploadResult = await port.uploadRuntimeV3( - { programSt, context: deviceContext }, - makePlatformLog(emit, 'upload'), - ) - if (!uploadResult.ok) { - return bailError(emit, 'upload', 'Failed to upload to Runtime v3.', uploadResult.errors) - } - emit({ stage: 'done', message: 'Runtime v3 upload complete.', level: 'info' }) - return { success: true, md5, uploaded: true } - } - // Run arduino-cli compile. Editor: spawns the binary. Web: HTTP // POST. Both consume the same `files` map + `argv`. emit({ stage: 'arduino-compile', message: 'Compiling Arduino firmware...', level: 'info' }) diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index aaf0c4800..7f49abeb9 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -74,6 +74,11 @@ const Board = memo(function () { const [previewImage, setPreviewImage] = useState('') const [formattedBoardState, setFormattedBoardState] = useState('') const [showPythonWarning, setShowPythonWarning] = useState(false) + // Human-readable label of the function-block kind(s) the target can't + // host (e.g. "Python", "C/C++", "C/C++ and Python") — drives the + // warning modal's copy so the same dialog serves Arduino (Python only) + // and Runtime v3 (neither C/C++ nor Python). + const [unsupportedBlocksLabel, setUnsupportedBlocksLabel] = useState('Python') const [showV4FeaturesWarning, setShowV4FeaturesWarning] = useState(false) const [v4FeaturesAffected, setV4FeaturesAffected] = useState<{ hasServers: boolean; hasRemoteDevices: boolean }>({ hasServers: false, @@ -286,8 +291,23 @@ const Board = memo(function () { const hasPythonFunctionBlocks = pous.some( (pou) => pou.pouType === 'function-block' && pou.body.language === 'python', ) - - if (!targetCaps.pythonFunctionBlocks && hasPythonFunctionBlocks) { + const hasCppFunctionBlocks = pous.some((pou) => pou.pouType === 'function-block' && pou.body.language === 'cpp') + + // OpenPLC Runtime v3 can host neither C/C++ nor Python function + // blocks: both lower to strucpp `{external ...}` inline-C that v3's + // MatIEC toolchain can't compile. Other targets are gated per + // capability (Arduino: no Python; v4 / simulator: both fine). Warn + // on switch — same soft prompt Arduino shows for Python — and let + // the user proceed (compilation will fail on the device if they do). + const isRuntimeV3Target = normalizedBoard === 'OpenPLC Runtime v3' + const pythonUnsupported = (!targetCaps.pythonFunctionBlocks || isRuntimeV3Target) && hasPythonFunctionBlocks + const cppUnsupported = isRuntimeV3Target && hasCppFunctionBlocks + + if (pythonUnsupported || cppUnsupported) { + const label = [cppUnsupported ? 'C/C++' : null, pythonUnsupported ? 'Python' : null] + .filter(Boolean) + .join(' and ') + setUnsupportedBlocksLabel(label) setPendingBoardChange({ board: normalizedBoard, formattedBoard: board }) setShowPythonWarning(true) return @@ -735,15 +755,17 @@ const Board = memo(function () { - Python Function Blocks Not Supported + {unsupportedBlocksLabel} Function Blocks Not Supported

- The selected target ({pendingBoardChange?.formattedBoard}) does not support Python Function Blocks. + The selected target ({pendingBoardChange?.formattedBoard}) does not support {unsupportedBlocksLabel}{' '} + Function Blocks.

- Your project contains Python Function Blocks that will cause compilation to fail on this target. To use - this target, you must remove all Python Function Blocks from your project. + Your project contains {unsupportedBlocksLabel} Function Blocks that will cause compilation to fail on this + target. To use this target, you must remove all {unsupportedBlocksLabel} Function Blocks from your + project.