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.