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
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,54 @@ describe('createEditorCompilerPlatformPort', () => {
expect(log).toHaveBeenCalledWith(expect.stringContaining('lib install failed'), 'error')
})

// ---- transpileXmlToSt — xml2stArgs forwarding (STRUCT drift regression) ----

it('transpileXmlToSt forwards args.xml2stArgs to handleTranspileXMLtoST verbatim', async () => {
// Regression guard for the editor/web STRUCT drift bug: the
// shared pipeline owns the xml2st flag set as an array of CLI
// tokens, and the editor adapter must thread that array into
// handleTranspileXMLtoST as the third positional arg — the
// handler then splices it straight into the spawned xml2st argv.
// Editor's local xml2st is trusted, so the adapter passes the
// array through verbatim (no filtering).
const handleTranspileXMLtoST = jest.fn(async () => undefined)
const tmp = mkdtempSync(join(tmpdir(), 'xml2st-args-'))
try {
const port = createEditorCompilerPlatformPort(
makeHandlers({ handleTranspileXMLtoST }),
makeContext({ sourceTargetFolderPath: tmp }),
)
// The handler stub never produces a program.st, so the readFile
// after the spawn-equivalent step throws — that's fine, we only
// care about the xml2stArgs argument forwarded to the handler.
await port.transpileXmlToSt({ xml: '<plc/>', xml2stArgs: ['--keep-structs'] }, () => undefined)
expect(handleTranspileXMLtoST).toHaveBeenCalledTimes(1)
const callArgs = handleTranspileXMLtoST.mock.calls[0]!
expect(callArgs[2]).toEqual(['--keep-structs'])
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})

it('transpileXmlToSt forwards an empty xml2stArgs array verbatim', async () => {
// The adapter must not "helpfully" inject defaults when the
// pipeline asked for nothing — that would be the exact kind of
// silent drift the shared port contract exists to prevent.
const handleTranspileXMLtoST = jest.fn(async () => undefined)
const tmp = mkdtempSync(join(tmpdir(), 'xml2st-empty-args-'))
try {
const port = createEditorCompilerPlatformPort(
makeHandlers({ handleTranspileXMLtoST }),
makeContext({ sourceTargetFolderPath: tmp }),
)
await port.transpileXmlToSt({ xml: '<plc/>', xml2stArgs: [] }, () => undefined)
const callArgs = handleTranspileXMLtoST.mock.calls[0]!
expect(callArgs[2]).toEqual([])
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})

// ---- uploadArduinoBoard — port wiring (regression for issue #5) ----

it('uploadArduinoBoard forwards args.port to the handler as communicationPort', async () => {
Expand Down
46 changes: 29 additions & 17 deletions src/backend/editor/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,8 @@

checkStrucppAvailability(): MethodsResult<string> {
try {
const { getVersion } = loadStrucpp()

Check warning on line 415 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe array destructuring of a tuple element with an error typed value
return { success: true, data: getVersion() }

Check warning on line 416 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe call of a(n) `error` type typed value

Check warning on line 416 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
} catch {
throw new Error('STruC++ not available. Run "npm run setup:binaries" to install it.')
}
Expand Down Expand Up @@ -720,15 +720,19 @@
async handleTranspileXMLtoST(
generatedXMLFilePath: string,
handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void,
extraXml2stArgs: readonly string[],
) {
return new Promise<MethodsResult<string | Buffer>>((resolve, reject) => {
// `--keep-structs` tells xml2st to emit user-defined STRUCT data
// types as native `TYPE name : STRUCT … END_STRUCT;` declarations
// instead of rewriting them as FUNCTION_BLOCKs (matiec's legacy
// workaround). Strucpp parses STRUCT natively and rejects the FB
// rewrite as a type-vs-instance mismatch — every program build in
// the editor targets strucpp now, so we always set the flag.
const executeCommand = this.#executeXml2st(['--generate-st', generatedXMLFilePath, '--keep-structs'])
// `extraXml2stArgs` comes from the shared pipeline's
// `TranspileXmlToStArgs.xml2stArgs` — the single source of truth
// for xml2st flag semantics across editor and web. Editor passes
// them through verbatim (trusted local binary); web's adapter
// filters against its known-args allowlist before sending to the
// compile-service. Strucpp targets currently pass
// `['--keep-structs']` (native STRUCT declarations vs matiec's
// legacy struct→FB rewrite); future flags appear here as the
// pipeline opts into them.
const executeCommand = this.#executeXml2st(['--generate-st', generatedXMLFilePath, ...extraXml2stArgs])

let stderrData = ''

Expand Down Expand Up @@ -2225,9 +2229,13 @@

const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml')
try {
await this.handleTranspileXMLtoST(generatedXMLFilePath, (data, logLevel) => {
_mainProcessPort.postMessage({ logLevel, message: data })
})
await this.handleTranspileXMLtoST(
generatedXMLFilePath,
(data, logLevel) => {
_mainProcessPort.postMessage({ logLevel, message: data })
},
['--keep-structs'],
)
} catch (error) {
_mainProcessPort.postMessage({
logLevel: 'error',
Expand Down Expand Up @@ -2262,7 +2270,7 @@
_mainProcessPort.postMessage({
logLevel,
message: data,
...(compileError ? { compileError } : {}),

Check warning on line 2273 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
})
},
{ hasCBlocks, pous: knownPous, libraries, missingLibraries },
Expand Down Expand Up @@ -2471,13 +2479,17 @@

// Stage 2: xml2st spawn (shared with the program-build path).
try {
await this.handleTranspileXMLtoST(xmlPath, (data, logLevel) => {
// xml2st's stdout doubles as progress + error stream; surface
// it verbatim so the user sees the same diagnostics the
// program-build path produces.
const message = typeof data === 'string' ? data : data.toString()
post(message, logLevel ?? 'info')
})
await this.handleTranspileXMLtoST(
xmlPath,
(data, logLevel) => {
// xml2st's stdout doubles as progress + error stream; surface
// it verbatim so the user sees the same diagnostics the
// program-build path produces.
const message = typeof data === 'string' ? data : data.toString()
post(message, logLevel ?? 'info')
},
['--keep-structs'],
)
} catch (error) {
bail(`xml2st failed: ${getErrorMessage(error)}`)
return
Expand Down
12 changes: 8 additions & 4 deletions src/backend/editor/compiler/editor-compiler-platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
* adapter computes the same hash via `spark-md5`; both outputs
* are byte-identical.
*/
async computeMd5(input: string): Promise<string> {

Check warning on line 150 in src/backend/editor/compiler/editor-compiler-platform-port.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Async method 'computeMd5' has no 'await' expression
return createHash('md5').update(input).digest('hex')
},

Expand All @@ -164,10 +164,14 @@
await fs.mkdir(dirname(xmlPath), { recursive: true })
await fs.writeFile(xmlPath, args.xml, 'utf-8')

await handlers.handleTranspileXMLtoST(xmlPath, (chunk, level) => {
const message = typeof chunk === 'string' ? chunk : chunk.toString()
log(message, level ?? 'info')
})
await handlers.handleTranspileXMLtoST(
xmlPath,
(chunk, level) => {
const message = typeof chunk === 'string' ? chunk : chunk.toString()
log(message, level ?? 'info')
},
args.xml2stArgs,
)

const programStPath = join(context.sourceTargetFolderPath, 'program.st')
const programSt = await fs.readFile(programStPath, 'utf-8')
Expand Down
9 changes: 9 additions & 0 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 Down Expand Up @@ -167,6 +167,15 @@
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.any(Function),
)
expect(port.compileArduino).toHaveBeenCalledTimes(1)
expect(port.uploadRuntimeV4).not.toHaveBeenCalled()
expect(port.uploadArduinoBoard).not.toHaveBeenCalled()
Expand Down
14 changes: 13 additions & 1 deletion 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 @@ -326,7 +326,19 @@
// on editor, HTTP /generate-st on web).
// ---------------------------------------------------------------------
emit({ stage: 'st', message: 'Generating Structured Text...', level: 'info' })
const stResult = await port.transpileXmlToSt({ xml: plcXml }, makePlatformLog(emit, 'st'))
// `['--keep-structs']` — strucpp parses native `STRUCT` declarations
// and rejects matiec's legacy struct→FB rewrite as a type-vs-instance
// mismatch. Editor's local xml2st always passed `--keep-structs`;
// pre-pipeline this was hardcoded inside its compiler-module while
// the web's compile-service `/generate-st` endpoint ran without it,
// causing structs to compile on the desktop and fail on the web.
// The flag set is now part of the port contract so both adapters
// observe the same tokens — future xml2st flags get appended here
// at this single call site.
const stResult = await port.transpileXmlToSt(
{ xml: plcXml, xml2stArgs: ['--keep-structs'] },
makePlatformLog(emit, 'st'),
)
if (!stResult.ok || !stResult.programSt) {
if (stResult.errors && stResult.errors.length > 0) {
emitCompileErrorEvents(
Expand Down
24 changes: 23 additions & 1 deletion src/middleware/shared/ports/compiler-platform-port.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/**
* Thin platform-bridge for the shared compile pipeline.
*
Expand Down Expand Up @@ -81,9 +81,31 @@
// ---------------------------------------------------------------------------

/** `xml2st` input: a single XML string (the IEC 61131-3 PLC XML the
* shared `XmlGenerator` produces). Same input on both platforms. */
* shared `XmlGenerator` produces) plus an array of extra CLI tokens
* to append to the xml2st invocation. Defined here (not on either
* adapter) because xml2st flag drift was the root cause of the
* initial cross-platform STRUCT bug — editor's local xml2st passed
* `--keep-structs` but the web's compile-service `/generate-st`
* endpoint hardcoded an unflagged invocation, so structs declared
* in the project compiled fine on the desktop and blew up on the
* web with `Undefined type 'MY_STRUCT'` errors out of strucpp.
* Pushing the flag set into a single shared field means any future
* xml2st option is a one-line pipeline change with no per-platform
* drift possible.
*
* Editor's adapter passes `xml2stArgs` verbatim to the local
* binary (trusted). Web's adapter filters against its own
* known-args allowlist before forwarding to the compile-service
* `/generate-st` endpoint, logging a warning for anything it
* doesn't recognise (defence in depth — service has its own
* allowlist too). */
export interface TranspileXmlToStArgs {
xml: string
/** Extra CLI tokens to append after `--generate-st <file>` in the
* xml2st invocation. For strucpp targets the shared pipeline
* sets `['--keep-structs']`; future flags get added here at the
* single call site in `runCompilePipeline`. */
xml2stArgs: readonly string[]
}

export interface TranspileXmlToStResult {
Expand Down
Loading