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
60 changes: 26 additions & 34 deletions src/backend/shared/compile/__tests__/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Tests for the shared compile pipeline orchestrator.
*
Expand All @@ -5,7 +5,7 @@
* methods. Each branch (simulator / runtime v4 / runtime v3 /
* arduino-direct, with `compileOnly` variants for each) is exercised
* here by mocking the port + the heavy shared dependencies
* (`runProgramBuildPipeline`, `XmlGenerator`).
* (`runProgramBuildPipeline`).
* The actual content-authoring steps (defines, confs, composers) are
* covered by their own unit tests; this suite focuses on the
* orchestration — call ordering, branch dispatch, error propagation,
Expand All @@ -21,9 +21,6 @@

// Mocks for heavy shared deps. Use `jest.fn()` so individual tests
// can override `.mockReturnValueOnce` / `.mockResolvedValueOnce`.
jest.mock('../../utils/PLC/xml-generator', () => ({
XmlGenerator: jest.fn(),
}))
jest.mock('../../library/program-build-pipeline', () => ({
runProgramBuildPipeline: jest.fn(),
}))
Expand Down Expand Up @@ -59,14 +56,12 @@
})),
}))

import { XmlGenerator } from '../../utils/PLC/xml-generator'
import { runProgramBuildPipeline } from '../../library/program-build-pipeline'
import { isStrucppCompatibleRuntime } from '../../firmware/runtime-version-gate'
import { generateRuntimeConfs } from '../steps/generate-confs'

import { runCompilePipeline, type RunCompilePipelineArgs, type PipelineProgressEvent } from '../pipeline'

const mockedXmlGen = XmlGenerator as jest.MockedFunction<typeof XmlGenerator>
const mockedConfs = generateRuntimeConfs as jest.MockedFunction<typeof generateRuntimeConfs>
const mockedStrucpp = runProgramBuildPipeline as jest.MockedFunction<typeof runProgramBuildPipeline>
const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction<typeof isStrucppCompatibleRuntime>
Expand All @@ -78,7 +73,7 @@
function makePort(overrides: Partial<CompilerPlatformPort> = {}): jest.Mocked<CompilerPlatformPort> {
return {
computeMd5: jest.fn().mockResolvedValue('a'.repeat(32)),
transpileXmlToSt: jest.fn().mockResolvedValue({ ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }),
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, 2, 3]) }),
Expand Down Expand Up @@ -136,8 +131,6 @@

beforeEach(() => {
jest.clearAllMocks()
// Default-mock: XML generation succeeds.
mockedXmlGen.mockReturnValue({ ok: true, data: '<plc/>', message: 'ok' } as never)
// Default-mock: strucpp succeeds with empty file map.
mockedStrucpp.mockReturnValue({
success: true,
Expand Down Expand Up @@ -166,14 +159,13 @@
expect(result.success).toBe(true)
expect(result.binary).toBeInstanceOf(Uint8Array)
expect(result.uploaded).toBe(false)
expect(port.transpileXmlToSt).toHaveBeenCalledTimes(1)
// The pipeline owns xml2st flag semantics: every strucpp target
// gets `xml2stArgs: ['--keep-structs']`. Regression guard for
// the editor/web STRUCT drift bug — see compiler-platform-port.ts
// comment. Future flags get added to this array, not to a
// typed boolean on the port (the port stays format-agnostic).
expect(port.transpileXmlToSt).toHaveBeenCalledWith(
expect.objectContaining({ xml2stArgs: ['--keep-structs'] }),
expect(port.transpileToSt).toHaveBeenCalledTimes(1)
// The pipeline hands the transpiler the (preprocessed) project IR and a
// log callback; the port impl owns xml2st-vs-JSON backend selection and any
// format-specific flags internally (see transpiler-mode.ts). The pipeline
// stays format-agnostic — it only passes { projectData }.
expect(port.transpileToSt).toHaveBeenCalledWith(
expect.objectContaining({ projectData: expect.anything() }),
expect.any(Function),
)
expect(port.compileArduino).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -272,9 +264,8 @@
const result = await runCompilePipeline(makeArgs({ projectData }), port, emit)

expect(result.success).toBe(false)
// Validation runs before XML generation / xml2st.
expect(mockedXmlGen).not.toHaveBeenCalled()
expect(port.transpileXmlToSt).not.toHaveBeenCalled()
// Validation runs before the transpile step.
expect(port.transpileToSt).not.toHaveBeenCalled()
// The user-facing error names the POU and the kind of block.
const validateError = events.find((e) => e.stage === 'validate' && e.level === 'error')
expect(validateError?.message).toContain('POU "main"')
Expand Down Expand Up @@ -771,19 +762,9 @@
// ---------------------------------------------------------------------------

describe('runCompilePipeline — failure propagation', () => {
it('returns success=false when XmlGenerator reports failure', async () => {
mockedXmlGen.mockReturnValueOnce({ ok: false, data: undefined, message: 'malformed pou' } as never)
const port = makePort()
const { events, emit } = captureEvents()
const result = await runCompilePipeline(makeArgs(), port, emit)
expect(result.success).toBe(false)
expect(events.some((e) => e.stage === 'xml' && /malformed pou/.test(e.message))).toBe(true)
expect(port.transpileXmlToSt).not.toHaveBeenCalled()
})

it('returns success=false when transpileXmlToSt reports failure', async () => {
it('returns success=false when transpileToSt reports failure', async () => {
const port = makePort({
transpileXmlToSt: jest.fn().mockResolvedValue({
transpileToSt: jest.fn().mockResolvedValue({
ok: false,
errors: [{ message: 'bad xml', line: 1, column: 1, severity: 'error' }],
}),
Expand Down Expand Up @@ -828,6 +809,17 @@
expect(result.success).toBe(false)
})

it('returns success=false when a simulator build produces no .hex binary', async () => {
// Simulator targets require the .hex artefact in memory (the loader can't
// find it on disk). A compile that reports ok but omits `binary` must fail
// with a precise error rather than silently succeeding.
const port = makePort({ compileArduino: jest.fn().mockResolvedValue({ ok: true }) })
const { events, emit } = captureEvents()
const result = await runCompilePipeline(makeArgs(), port, emit)
expect(result.success).toBe(false)
expect(events.some((e) => e.stage === 'arduino-compile' && /did not produce a \.hex/.test(e.message))).toBe(true)
})

it('returns success=false when uploadRuntimeV4 reports failure', async () => {
const port = makePort({
uploadRuntimeV4: jest.fn().mockResolvedValue({ ok: false, errors: [] }),
Expand Down Expand Up @@ -945,7 +937,7 @@

it('emits per-error events with structured compileError payloads on transpile failure', async () => {
const port = makePort({
transpileXmlToSt: jest.fn().mockResolvedValue({
transpileToSt: jest.fn().mockResolvedValue({
ok: false,
errors: [
{ message: 'bad syntax', line: 5, column: 3, severity: 'error' },
Expand Down Expand Up @@ -1048,7 +1040,7 @@
// ports with `vi.fn()` never invoke the callback, leaving the
// lambda body uncovered — this test pins the wiring explicitly.
const port = makePort({
transpileXmlToSt: jest.fn().mockImplementation(async (_args, log) => {
transpileToSt: jest.fn().mockImplementation(async (_args, log) => {
log('xml2st spawned subprocess', 'info')
log('xml2st: parsed 5 POUs', 'info')
return { ok: true, programSt: 'PROGRAM main\nEND_PROGRAM' }
Expand Down
52 changes: 14 additions & 38 deletions src/backend/shared/library/__tests__/build-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Tests for the library build pipeline.
*
* The XmlGenerator is mocked because it depends on the frontend
* xml-generator helpers; we exercise the orchestration here, not
* actual XML serialisation (covered by xml-generator's own tests).
* `prepareXmlForLibraryBuild` no longer generates PLCopen XML — the
* old xml2st flow was replaced by an in-process JSON → ST transpiler.
* The function now only validates the manifest and returns the stubbed
* project data (plus the POU inventory the splitter needs); the actual
* transpile happens later via `LibraryBuildPort.transpileToSt`.
* Strucpp is mocked via the runtime's test escape hatch — the build
* pipeline must remain pure (no real strucpp load) for these tests.
*/
Expand All @@ -15,12 +17,6 @@
// Mocks
// ---------------------------------------------------------------------------

const mockXmlGenerator = jest.fn()
jest.mock('../../utils/PLC/xml-generator', () => ({
XmlGenerator: (...args: unknown[]) => mockXmlGenerator(...args),
}))

// Import after mocks
import { __setStrucppRuntimeForTests } from '../strucpp-runtime'
import {
__TESTING__,
Expand Down Expand Up @@ -289,7 +285,6 @@
expect('error' in result).toBe(true)
if (!('error' in result)) return
expect(result.error).toContain('library.json is invalid')
expect(mockXmlGenerator).not.toHaveBeenCalled()
})

it('formats multi-line error reports with one bullet per validation issue', () => {
Expand All @@ -299,35 +294,17 @@
expect(bulletCount).toBeGreaterThanOrEqual(3)
})

it('returns a structured error when XML generation fails', () => {
mockXmlGenerator.mockReturnValue({ ok: false, message: 'no main pou', data: undefined })
const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON)
expect('error' in result).toBe(true)
if (!('error' in result)) return
expect(result.error).toContain('no main pou')
})

it('falls back to "unknown error" when XmlGenerator omits a message', () => {
mockXmlGenerator.mockReturnValue({ ok: false, data: undefined })
it('returns stubbed projectData + knownPous (including stub) + parsed manifest on success', () => {
const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON)
if (!('error' in result)) throw new Error('expected error')
expect(result.error).toContain('unknown error')
})

it('treats ok=true but empty data as an error', () => {
mockXmlGenerator.mockReturnValue({ ok: true, message: 'XML generated', data: '' })
const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON)
expect('error' in result).toBe(true)
})

it('returns xml + knownPous (including stub) + parsed manifest on success', () => {
mockXmlGenerator.mockReturnValue({ ok: true, message: 'XML generated', data: '<plc/>' })
const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON)
expect('xml' in result).toBe(true)
if (!('xml' in result)) return
expect(result.xml).toBe('<plc/>')
// `error` is the union discriminant — its absence means success.
expect('error' in result).toBe(false)
if ('error' in result) return
expect(result.manifest.name).toBe('demo_lib')

// The stubbed project carries the library's POUs plus the
// synthesised `main` program the transpiler requires.
expect(result.projectData.pous.map((p) => p.data.name)).toEqual(['TankController', STUB.STUB_PROGRAM_NAME])

// POUs from the project + the stub program
const names = result.knownPous.map((p) => p.name)
expect(names).toEqual(['TankController', STUB.STUB_PROGRAM_NAME])
Expand All @@ -337,7 +314,6 @@
})

it('maps each POU type to the correct splitter kind', () => {
mockXmlGenerator.mockReturnValue({ ok: true, data: '<plc/>' })
const project = makeLibraryProject({
pous: [
{
Expand All @@ -364,7 +340,7 @@
],
})
const result = prepareXmlForLibraryBuild(project, VALID_MANIFEST_JSON)
if (!('knownPous' in result)) throw new Error('expected success')
if ('error' in result) throw new Error('expected success')
const byName = Object.fromEntries(result.knownPous.map((p) => [p.name, p.kind]))
expect(byName).toEqual({ Add2: 'FUNCTION', Tank: 'FUNCTION_BLOCK', main: 'PROGRAM' })
})
Expand Down
Loading
Loading