Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/docx-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/docx-core/src/integration/libreoffice-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,54 @@ export function resolveSoffice(): string | null {
return candidates.find((c) => existsSync(c)) ?? null;
}

const probeResults = new Map<string, Promise<boolean>>();

/**
* 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<boolean> {
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
Expand Down
10 changes: 9 additions & 1 deletion packages/odf-core/src/compare/lo_inline_roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/odf-core/src/compare/lo_paragraph_roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 3 additions & 10 deletions packages/odf-core/src/convert/lo_convert_differential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>probe</w:t></w:r></w:p></w:body></w:document>' }],
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;
}

Expand Down
19 changes: 6 additions & 13 deletions packages/odf-core/src/roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
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';

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));
Expand Down Expand Up @@ -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());
Expand Down