Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/backend/shared/compile/__tests__/pipeline-runtime-v3.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof runProgramBuildPipeline>

function makePort(overrides: Partial<CompilerPlatformPort> = {}): jest.Mocked<CompilerPlatformPort> {
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<CompilerPlatformPort>
}

const deviceContext: PlatformDeviceContext = { kind: 'editor-https', ip: '192.168.1.199', jwt: 'jwt' }

function makeArgs(overrides: Partial<RunCompilePipelineArgs> = {}): 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)
})
})
82 changes: 47 additions & 35 deletions src/backend/shared/compile/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Shared OpenPLC compile pipeline.
*
Expand Down Expand Up @@ -591,7 +591,53 @@
}

// ---------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------
Expand Down Expand Up @@ -677,40 +723,6 @@
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' })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import type { TimingStats } from '@root/middleware/shared/ports/types'
import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context'
Expand Down Expand Up @@ -74,6 +74,11 @@
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,
Expand Down Expand Up @@ -286,8 +291,23 @@
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
Expand Down Expand Up @@ -735,15 +755,17 @@
<Modal open={showPythonWarning} onOpenChange={setShowPythonWarning}>
<ModalContent className='h-fit w-[500px]'>
<ModalHeader>
<ModalTitle>Python Function Blocks Not Supported</ModalTitle>
<ModalTitle>{unsupportedBlocksLabel} Function Blocks Not Supported</ModalTitle>
</ModalHeader>
<div className='flex flex-col gap-4'>
<p className='text-sm text-neutral-700 dark:text-neutral-300'>
The selected target ({pendingBoardChange?.formattedBoard}) does not support Python Function Blocks.
The selected target ({pendingBoardChange?.formattedBoard}) does not support {unsupportedBlocksLabel}{' '}
Function Blocks.
</p>
<p className='text-sm text-neutral-700 dark:text-neutral-300'>
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.
</p>
</div>
<ModalFooter className='flex justify-end gap-2'>
Expand Down
Loading