From 78a38fb9705528260c978daf5427a5c2b49c1304 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Fri, 3 Jul 2026 15:34:07 -0400 Subject: [PATCH] test(backend/shared): de-XML-ify stale compile/library-build suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These suites predate the XML/xml2st → in-process JSON-transpiler migration and never ran in CI (the unit-tests job was broken), so they drifted: - compile/pipeline.test.ts: rename transpileXmlToSt→transpileToSt, assert the current {projectData} call contract, drop the dead XmlGenerator mock + XML stage test, add a simulator-no-binary edge test. - library/build-pipeline.test.ts: drop the XmlGenerator mock and the removed XML-generation/empty-data paths; assert prepareXmlForLibraryBuild's current {projectData,knownPous,manifest}|{error} contract. - library/library-build-orchestrator.test.ts: fake port uses transpileToSt ({projectData}, log); remove plc.xml intermediates/logs; restore coverage. All three source files back to 100% funcs/lines/statements. 95 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/compile/__tests__/pipeline.test.ts | 60 ++--- .../library/__tests__/build-pipeline.test.ts | 52 ++-- .../library-build-orchestrator.test.ts | 234 ++++++++++++++++-- 3 files changed, 259 insertions(+), 87 deletions(-) diff --git a/src/backend/shared/compile/__tests__/pipeline.test.ts b/src/backend/shared/compile/__tests__/pipeline.test.ts index 1c8cf9877..170260375 100644 --- a/src/backend/shared/compile/__tests__/pipeline.test.ts +++ b/src/backend/shared/compile/__tests__/pipeline.test.ts @@ -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, @@ -21,9 +21,6 @@ import type { // 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(), })) @@ -59,14 +56,12 @@ jest.mock('../steps/generate-confs', () => ({ })), })) -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 const mockedConfs = generateRuntimeConfs as jest.MockedFunction const mockedStrucpp = runProgramBuildPipeline as jest.MockedFunction const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction @@ -78,7 +73,7 @@ const mockedVersionGate = isStrucppCompatibleRuntime as jest.MockedFunction = {}): jest.Mocked { 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]) }), @@ -136,8 +131,6 @@ function captureEvents() { beforeEach(() => { jest.clearAllMocks() - // Default-mock: XML generation succeeds. - mockedXmlGen.mockReturnValue({ ok: true, data: '', message: 'ok' } as never) // Default-mock: strucpp succeeds with empty file map. mockedStrucpp.mockReturnValue({ success: true, @@ -166,14 +159,13 @@ describe('runCompilePipeline — simulator path', () => { 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) @@ -272,9 +264,8 @@ describe('runCompilePipeline — blank FBD variable guard', () => { 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"') @@ -771,19 +762,9 @@ describe('runCompilePipeline — boardEntry shape variants', () => { // --------------------------------------------------------------------------- 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' }], }), @@ -828,6 +809,17 @@ describe('runCompilePipeline — failure propagation', () => { 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: [] }), @@ -945,7 +937,7 @@ describe('runCompilePipeline — side effects', () => { 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' }, @@ -1048,7 +1040,7 @@ describe('runCompilePipeline — side effects', () => { // 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' } diff --git a/src/backend/shared/library/__tests__/build-pipeline.test.ts b/src/backend/shared/library/__tests__/build-pipeline.test.ts index 1222dd6b0..fa0f37d12 100644 --- a/src/backend/shared/library/__tests__/build-pipeline.test.ts +++ b/src/backend/shared/library/__tests__/build-pipeline.test.ts @@ -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. */ @@ -15,12 +17,6 @@ import type { StrucppRuntime } from '../strucpp-runtime' // 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__, @@ -289,7 +285,6 @@ describe('prepareXmlForLibraryBuild', () => { 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', () => { @@ -299,35 +294,17 @@ describe('prepareXmlForLibraryBuild', () => { 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: '' }) - const result = prepareXmlForLibraryBuild(makeLibraryProject(), VALID_MANIFEST_JSON) - expect('xml' in result).toBe(true) - if (!('xml' in result)) return - expect(result.xml).toBe('') + // `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]) @@ -337,7 +314,6 @@ describe('prepareXmlForLibraryBuild', () => { }) it('maps each POU type to the correct splitter kind', () => { - mockXmlGenerator.mockReturnValue({ ok: true, data: '' }) const project = makeLibraryProject({ pous: [ { @@ -364,7 +340,7 @@ describe('prepareXmlForLibraryBuild', () => { ], }) 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' }) }) diff --git a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts index 0e2073c8c..4823c0a5a 100644 --- a/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts +++ b/src/backend/shared/library/__tests__/library-build-orchestrator.test.ts @@ -14,8 +14,13 @@ */ import type { LibraryBuildPort, VerifyCompileArgs } from '../../../../middleware/shared/ports/library-build-port' +import type { TranspileToStArgs, TranspileToStResult } from '../../../../middleware/shared/ports/compiler-platform-port' import type { PLCProjectData } from '../../types/PLC/open-plc' +// ST the fake `transpileToSt` port method emits. Fixed content so the +// verification-cache tests can precompute the harness MD5 off it. +const FAKE_PROGRAM_ST = 'PROGRAM main\n(* transpiled *)\nEND_PROGRAM\n' + // --------------------------------------------------------------------------- // Mocks for the inner shared helpers // --------------------------------------------------------------------------- @@ -48,6 +53,8 @@ interface PortHarness { missing: string[] verifyResult: { success: boolean; message?: string } verifyCalls: VerifyCompileArgs[] + transpileResult: TranspileToStResult + transpileCalls: TranspileToStArgs[] /** Programmable error for whichever method the test wants to fail. */ throwOn: Partial> } @@ -61,6 +68,8 @@ function makePort(): PortHarness { missing: [], verifyResult: { success: true }, verifyCalls: [], + transpileResult: { ok: true, programSt: FAKE_PROGRAM_ST }, + transpileCalls: [], throwOn: {}, } harness.port = { @@ -70,9 +79,12 @@ function makePort(): PortHarness { // distinguishable. return `md5-${input.length}-${input.charCodeAt(0) ?? 0}` }, - async transpileXmlToSt({ xml }) { - if (harness.throwOn.transpileXmlToSt) throw harness.throwOn.transpileXmlToSt - return { ok: true, programSt: `PROGRAM main\n(* from xml: ${xml.length} bytes *)\nEND_PROGRAM\n` } + async transpileToSt(args: TranspileToStArgs, log) { + if (harness.throwOn.transpileToSt) throw harness.throwOn.transpileToSt + harness.transpileCalls.push(args) + // Exercise the orchestrator's log-forwarding lambda. + log('transpiler: parsing project IR', 'info') + return harness.transpileResult }, async readBuildFile(_projectPath: string, relPath: string) { if (harness.throwOn.readBuildFile) throw harness.throwOn.readBuildFile @@ -122,7 +134,7 @@ beforeEach(() => { mockComposeVerify.mockClear() mockPrepareXml.mockReturnValue({ - xml: '...', + projectData: projectDataEmpty(), knownPous: [], manifest: { name: 'lib', version: '0.1.0', namespace: 'lib', extra: {} }, }) @@ -155,22 +167,23 @@ describe('runLibraryBuildPipeline', () => { expect(harness.files.get('build/lib.stlib')).toMatch(/^\{[\s\S]+\}\n$/) // Verification cache persisted with the MD5 the orchestrator computed. expect(harness.files.has('build/.verify-cache-library.json')).toBe(true) - // Intermediates (plc.xml, program.st) live in memory only — the - // orchestrator does NOT persist them. See the path-constants - // comment in library-build-orchestrator.ts for the rationale. - expect(harness.files.has('build/library/src/plc.xml')).toBe(false) + // Intermediates (program.st) live in memory only — the ST is + // produced in-process by `transpileToSt` and never persisted. + // See the path-constants comment in library-build-orchestrator.ts. expect(harness.files.has('build/library/src/program.st')).toBe(false) // Stage messages flow through in order. expect(events.map((e) => e.message)).toEqual( expect.arrayContaining([ 'Starting library build...', 'Manifest OK — building "lib" v0.1.0.', - 'Compiling file plc.xml', + 'Transpiling project to Structured Text', 'Verifying with OpenPLC Simulator (avr-gcc)...', 'Compiling library archive...', 'Library built successfully: build/lib.stlib', ]), ) + // The stubbed projectData from Stage 1 is what the transpiler sees. + expect(harness.transpileCalls).toHaveLength(1) }) it('does not call deleteBuildSubtree (intermediates are no longer persisted)', async () => { @@ -247,7 +260,7 @@ describe('runLibraryBuildPipeline', () => { expect(aux.dependencyRefs).toEqual([{ name: 'oscat-basic', version: '1.0.0' }]) }) - it('aborts before xml2st when the project enables an unresolved library', async () => { + it('aborts before the strucpp compile when the project enables an unresolved library', async () => { const harness = makePort() harness.missing = ['ghost-lib'] const { events, emit } = captureEvents() @@ -277,9 +290,8 @@ describe('runLibraryBuildPipeline', () => { const harness = makePort() // Pre-seed the cache. computeMd5 in the harness is deterministic // off program.st length + first char; the orchestrator's value - // will match this when the same xml2st output replays. - const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` - const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + // will match this when the same transpiler output replays. + const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { events, emit } = captureEvents() @@ -300,8 +312,7 @@ describe('runLibraryBuildPipeline', () => { it('cleanBuild forces a fresh verification regardless of cache', async () => { const harness = makePort() - const programSt = `PROGRAM main\n(* from xml: 24 bytes *)\nEND_PROGRAM\n` - const expectedMd5 = `md5-${programSt.length}-${programSt.charCodeAt(0)}` + const expectedMd5 = `md5-${FAKE_PROGRAM_ST.length}-${FAKE_PROGRAM_ST.charCodeAt(0)}` harness.files.set('build/.verify-cache-library.json', JSON.stringify({ md5: expectedMd5, success: true })) const { emit } = captureEvents() @@ -319,6 +330,52 @@ describe('runLibraryBuildPipeline', () => { expect(harness.verifyCalls).toHaveLength(1) }) + it('runs a fresh verification when the cache read throws', async () => { + const harness = makePort() + // Throw only on the cache read; the manifest read (Stage 0) must + // still succeed so we reach the cache-consult path. + const realRead = harness.port.readBuildFile.bind(harness.port) + harness.port.readBuildFile = async (projectPath, relPath) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache read blew up') + return realRead(projectPath, relPath) + } + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Cache read failed → treated as a miss → fresh verification runs. + expect(harness.verifyCalls).toHaveLength(1) + }) + + it('runs a fresh verification when the cached file is malformed JSON', async () => { + const harness = makePort() + harness.files.set('build/.verify-cache-library.json', '{ not valid json') + const { emit } = captureEvents() + + await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Malformed cache → fall through to a real verification run. + expect(harness.verifyCalls).toHaveLength(1) + }) + it('surfaces a verification failure as a warning but still emits the .stlib', async () => { const harness = makePort() harness.verifyResult = { success: false, message: 'AVR ran out of flash' } @@ -418,4 +475,151 @@ describe('runLibraryBuildPipeline', () => { expect(mockLibraryBuild).not.toHaveBeenCalled() expect(harness.verifyCalls).toHaveLength(0) }) + + it('fails when reading library.json throws an IO error', async () => { + const harness = makePort() + harness.throwOn.readBuildFile = new Error('disk on fire') + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Could not read library\.json: disk on fire/) + expect(mockPrepareXml).not.toHaveBeenCalled() + }) + + it('fails when the transpiler reports an error', async () => { + const harness = makePort() + harness.transpileResult = { + ok: false, + errors: [{ message: 'unexpected token in POU body', line: 1, column: 1, severity: 'error' }], + } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/transpile-from-json failed: unexpected token in POU body/) + expect(result.libraryName).toBe('lib') + expect(mockLibraryBuild).not.toHaveBeenCalled() + expect(harness.verifyCalls).toHaveLength(0) + }) + + it('falls back to a generic message when the transpiler returns ok=true but no ST', async () => { + const harness = makePort() + // ok=true with an undefined programSt still short-circuits, and + // with no `errors[]` the orchestrator uses its default message. + harness.transpileResult = { ok: true, programSt: undefined } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/transpile-from-json failed: transpile-from-json failed/) + }) + + it('treats a thrown verifyCompile as a failed (advisory) verification', async () => { + const harness = makePort() + // A non-Error throwable exercises the `String(error)` fallback in + // `formatError`. + harness.port.verifyCompile = async () => { + throw 'avr-gcc segfaulted' + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + // Verification failures are advisory — the build still succeeds. + expect(result.success).toBe(true) + expect(result.verification?.success).toBe(false) + expect(result.verification?.message).toBe('avr-gcc segfaulted') + expect(events.some((e) => e.level === 'warning' && /Verification reported issues/.test(e.message))).toBe(true) + }) + + it('warns but still ships the .stlib when the verification cache cannot be written', async () => { + const harness = makePort() + // Fail only the cache write; the .stlib write happens later and + // must still succeed. + const realWrite = harness.port.writeBuildFile.bind(harness.port) + harness.port.writeBuildFile = async (projectPath, relPath, content) => { + if (relPath === 'build/.verify-cache-library.json') throw new Error('cache dir read-only') + return realWrite(projectPath, relPath, content) + } + const { events, emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(true) + expect(harness.files.has('build/lib.stlib')).toBe(true) + expect(events.some((e) => e.level === 'warning' && /Could not write verification cache/.test(e.message))).toBe(true) + }) + + it('fails when the .stlib archive cannot be written', async () => { + const harness = makePort() + harness.port.writeBuildFile = async (_projectPath, relPath) => { + if (relPath === 'build/lib.stlib') throw new Error('out of disk space') + // Let the cache write succeed. + } + const { emit } = captureEvents() + + const result = await runLibraryBuildPipeline( + { + projectPath: '/project', + projectData: projectDataEmpty(), + verifyProjectData: projectDataEmpty(), + cleanBuild: false, + }, + harness.port, + emit, + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Could not write lib\.stlib: out of disk space/) + expect(result.libraryName).toBe('lib') + }) })