From b6849bb8c68269e85f8162cafb3f231a7035a26a Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Thu, 11 Jun 2026 06:04:13 -0400 Subject: [PATCH] test(odf-core): skip LibreOffice-gated tests when soffice is present but unusable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveSoffice() proves the binary EXISTS, not that it can launch. Under a restricted shell (observed: macOS Seatbelt via `codex exec --sandbox workspace-write` while implementing #411) soffice dies with SIGABRT during init, so the four existence-gated odf-core tests (ORTS-02, OCMPI-13, the issue-#367 inline/paragraph round-trips) FAILED after burning their 60-240s oracle timeouts instead of skipping — misleading sandboxed agents into diagnosing the machine's LibreOffice as broken when only their shell is. CONV-13 already solved this with an inline preflight probe. Extract it as probeSofficeUsable() next to resolveSoffice() in docx-core (a throwaway headless --convert-to with a fresh profile — the same first step the oracle itself performs, memoized per binary path), apply it to the three ungated tests, and refactor CONV-13 onto the shared helper. The real oracle calls stay outside the probe so genuine round-trip regressions still fail loudly; only launchability is skip-worthy. ORTS-02's local resolveSoffice duplicate is replaced by the shared export (a superset: it also honors SAFE_DOCX_SOFFICE_BIN). Verified both paths: real soffice → 19 files / 133 tests pass with the oracle genuinely running (tests 35.8s); ODF_SOFFICE_BIN=/usr/bin/false → same 133 green with all oracle work skipped (tests 1.0s). Ref: #411 --- packages/docx-core/src/index.ts | 5 +- .../src/integration/libreoffice-oracle.ts | 48 +++++++++++++++++++ .../src/compare/lo_inline_roundtrip.test.ts | 10 +++- .../compare/lo_paragraph_roundtrip.test.ts | 6 ++- .../convert/lo_convert_differential.test.ts | 13 ++--- packages/odf-core/src/roundtrip.test.ts | 19 +++----- 6 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/docx-core/src/index.ts b/packages/docx-core/src/index.ts index 2c17547..7fbd46e 100644 --- a/packages/docx-core/src/index.ts +++ b/packages/docx-core/src/index.ts @@ -119,8 +119,9 @@ export type { } from './primitives/track-changes-emitter.js'; // Re-export the LibreOffice accept/reject oracle (gated reference voter; callers skip when -// `resolveSoffice()` is null). odf-core's round-trip tests drive it with `.odt` jobs. -export { resolveSoffice, runLibreOfficeOracle, type OracleJob } from './integration/libreoffice-oracle.js'; +// `resolveSoffice()` is null or `probeSofficeUsable()` is false — the binary can exist yet +// abort on launch under a restricted shell). odf-core's round-trip tests drive it with `.odt` jobs. +export { resolveSoffice, probeSofficeUsable, runLibreOfficeOracle, type OracleJob } from './integration/libreoffice-oracle.js'; // Synthetic-DOCX fixture builders re-exported for downstream packages' test suites // (odf-core's DOCX→ODT conversion tests build their inputs with these). They live under diff --git a/packages/docx-core/src/integration/libreoffice-oracle.ts b/packages/docx-core/src/integration/libreoffice-oracle.ts index 47eba6f..987cd78 100644 --- a/packages/docx-core/src/integration/libreoffice-oracle.ts +++ b/packages/docx-core/src/integration/libreoffice-oracle.ts @@ -79,6 +79,54 @@ export function resolveSoffice(): string | null { return candidates.find((c) => existsSync(c)) ?? null; } +const probeResults = new Map>(); + +/** + * Preflight launchability probe. `resolveSoffice()` proves the binary EXISTS, not that it can + * launch: under a restricted shell (observed: macOS Seatbelt, e.g. `codex exec --sandbox + * workspace-write`) soffice dies with SIGABRT ("Abort trap: 6") during init, so a + * present-but-unusable binary would FAIL the gated oracle tests instead of skipping them. + * Callers check this after `resolveSoffice()` and skip-with-a-warning when it returns false; + * the real oracle calls stay outside any try/catch so genuine regressions still fail loudly. + * + * The probe is the same throwaway headless `--convert-to txt` the oracle uses to initialize its + * profile — the cheapest operation known to discriminate "can launch" from "aborts on init". + * Memoized per binary path so a multi-test file pays for one launch. + */ +export function probeSofficeUsable(soffice: string): Promise { + let result = probeResults.get(soffice); + if (!result) { + result = (async () => { + const work = mkdtempSync(path.join(os.tmpdir(), 'lo-probe-')); + try { + const inPath = path.join(work, 'probe-input.txt'); + const outDir = path.join(work, 'out'); + writeFileSync(inPath, 'probe'); + await runSoffice( + soffice, + [ + '--headless', + '--norestore', + '--nologo', + `-env:UserInstallation=${pathToFileURL(path.join(work, 'profile')).href}`, + '--convert-to', + 'txt', + '--outdir', + outDir, + inPath, + ], + 30_000, + ); + return existsSync(path.join(outDir, 'probe-input.txt')); + } finally { + rmSync(work, { recursive: true, force: true }); + } + })(); + probeResults.set(soffice, result); + } + return result; +} + const CONTENT_TYPES = ` diff --git a/packages/odf-core/src/compare/lo_inline_roundtrip.test.ts b/packages/odf-core/src/compare/lo_inline_roundtrip.test.ts index 4159ee2..cf6510b 100644 --- a/packages/odf-core/src/compare/lo_inline_roundtrip.test.ts +++ b/packages/odf-core/src/compare/lo_inline_roundtrip.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import { resolveSoffice, runLibreOfficeOracle } from '@usejunior/docx-core'; +import { probeSofficeUsable, resolveSoffice, runLibreOfficeOracle } from '@usejunior/docx-core'; import { compareOdf } from './index.js'; import { OdfArchive } from '../shared/odf/OdfArchive.js'; @@ -63,6 +63,10 @@ describe('LibreOffice accept/reject round-trip of the inline redline', () => { console.warn('[OCMPI-13] soffice not found — skipping LibreOffice round-trip (set ODF_SOFFICE_BIN to enable).'); return; } + if (!(await probeSofficeUsable(soffice))) { + console.warn('[OCMPI-13] soffice present but unusable (aborts on launch) — skipping LibreOffice round-trip.'); + return; + } const { contentXml: redline, stats } = compareOdf(await contentXml(ORIGINAL), await contentXml(REVISED), { author: 'RoundTrip', @@ -92,6 +96,10 @@ describe('LibreOffice accept/reject round-trip of the inline redline', () => { console.warn('[issue #367] soffice not found — skipping LibreOffice round-trip (set ODF_SOFFICE_BIN to enable).'); return; } + if (!(await probeSofficeUsable(soffice))) { + console.warn('[issue #367] soffice present but unusable (aborts on launch) — skipping LibreOffice round-trip.'); + return; + } // Formerly a characterization of the pre-existing Slice-1 defect: the deletion marker // anchored inside the inserted replacement paragraph while the end-of-document insertion diff --git a/packages/odf-core/src/compare/lo_paragraph_roundtrip.test.ts b/packages/odf-core/src/compare/lo_paragraph_roundtrip.test.ts index 101e2dc..433b27e 100644 --- a/packages/odf-core/src/compare/lo_paragraph_roundtrip.test.ts +++ b/packages/odf-core/src/compare/lo_paragraph_roundtrip.test.ts @@ -20,7 +20,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import { resolveSoffice, runLibreOfficeOracle, type OracleJob } from '@usejunior/docx-core'; +import { probeSofficeUsable, resolveSoffice, runLibreOfficeOracle, type OracleJob } from '@usejunior/docx-core'; import { compareOdf } from './index.js'; import { OdfArchive } from '../shared/odf/OdfArchive.js'; @@ -87,6 +87,10 @@ describe('LibreOffice accept/reject round-trip of the paragraph-granularity redl console.warn('[issue #367] soffice not found — skipping LibreOffice round-trip (set ODF_SOFFICE_BIN to enable).'); return; } + if (!(await probeSofficeUsable(soffice))) { + console.warn('[issue #367] soffice present but unusable (aborts on launch) — skipping LibreOffice round-trip.'); + return; + } const jobs: OracleJob[] = []; for (const c of COMPOSITIONS) { diff --git a/packages/odf-core/src/convert/lo_convert_differential.test.ts b/packages/odf-core/src/convert/lo_convert_differential.test.ts index 8dbbca2..d7df292 100644 --- a/packages/odf-core/src/convert/lo_convert_differential.test.ts +++ b/packages/odf-core/src/convert/lo_convert_differential.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; -import { resolveSoffice, runLibreOfficeOracle } from '@usejunior/docx-core'; +import { probeSofficeUsable, resolveSoffice, runLibreOfficeOracle } from '@usejunior/docx-core'; import { convertDocxToOdt } from './docx_to_odt.js'; import { OdfArchive } from '../shared/odf/OdfArchive.js'; @@ -48,15 +48,8 @@ describe('convertDocxToOdt — LibreOffice differential oracle', () => { console.warn('[CONV-13] soffice not found — skipping differential test (set ODF_SOFFICE_BIN to enable).'); return; } - // Preflight probe: soffice can resolve yet be unusable (observed: `Abort trap: 6` under - // macOS Launch Constraints). A broken oracle must SKIP this differential, not fail it. - try { - await runLibreOfficeOracle( - [{ op: 'identity', documentXml: 'probe' }], - soffice, - ); - } catch (err) { - console.warn(`[CONV-13] soffice present but unusable — skipping differential test: ${(err as Error).message.split('\n')[0]}`); + if (!(await probeSofficeUsable(soffice))) { + console.warn('[CONV-13] soffice present but unusable (aborts on launch) — skipping differential test.'); return; } diff --git a/packages/odf-core/src/roundtrip.test.ts b/packages/odf-core/src/roundtrip.test.ts index 9a13d0a..4b325df 100644 --- a/packages/odf-core/src/roundtrip.test.ts +++ b/packages/odf-core/src/roundtrip.test.ts @@ -1,11 +1,12 @@ import { execFile } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { describe, it, expect } from 'vitest'; +import { probeSofficeUsable, resolveSoffice } from '@usejunior/docx-core'; import { OdfArchive } from './shared/odf/OdfArchive.js'; import { OdfDocument } from './document.js'; @@ -13,18 +14,6 @@ import { OdfDocument } from './document.js'; const execFileAsync = promisify(execFile); const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), '__fixtures__/sample.odt'); -/** Resolve a LibreOffice binary, or null if none is available (test skips). */ -function resolveSoffice(): string | null { - const candidates = [ - process.env.ODF_SOFFICE_BIN, - '/opt/homebrew/bin/soffice', - '/usr/bin/soffice', - '/usr/local/bin/soffice', - '/Applications/LibreOffice.app/Contents/MacOS/soffice', - ].filter(Boolean) as string[]; - return candidates.find((c) => existsSync(c)) ?? null; -} - describe('ODF round trip', () => { it('[ORTS-01] open → replace_text → save → reopen yields the edited text, others unchanged', async () => { const archive = await OdfArchive.load(readFileSync(FIXTURE)); @@ -61,6 +50,10 @@ describe('ODF round trip', () => { console.warn('[ORTS-02] soffice not found — skipping LibreOffice open smoke (set ODF_SOFFICE_BIN to enable).'); return; } + if (!(await probeSofficeUsable(soffice))) { + console.warn('[ORTS-02] soffice present but unusable (aborts on launch) — skipping LibreOffice open smoke.'); + return; + } const archive = await OdfArchive.load(readFileSync(FIXTURE)); const doc = OdfDocument.fromContentXml(await archive.getContentXml());