170 lines
7.3 KiB
TypeScript
170 lines
7.3 KiB
TypeScript
/**
|
|
* Integration tests for the stdout/stderr streaming contract.
|
|
*
|
|
* Runs the bundled CLI as a real subprocess against a multi-page sample PDF.
|
|
* Asserts the user-facing behavior that mock unit tests cannot prove:
|
|
* - CLI never double-prints stdout (regression test for #398)
|
|
* - Java's progress logs reach the parent's stderr in real time, before the
|
|
* stdout payload finishes — the property that makes hour-long hybrid runs
|
|
* observable
|
|
* - Library API does not leak Java's output to the parent's stdout
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const rootDir = path.resolve(__dirname, '..', '..', '..');
|
|
const cliPath = path.resolve(__dirname, '..', 'dist', 'cli.js');
|
|
const jarPath = path.resolve(__dirname, '..', 'lib', 'opendataloader-pdf-cli.jar');
|
|
const samplePdf = path.join(rootDir, 'samples', 'pdf', '2408.02509v1.pdf');
|
|
|
|
interface Capture {
|
|
stdout: string;
|
|
stderr: string;
|
|
/** Wall-clock ms (since process spawn) of each chunk we received. */
|
|
timeline: Array<{ stream: 'out' | 'err'; size: number; at: number }>;
|
|
exitCode: number | null;
|
|
}
|
|
|
|
function captureSubprocess(command: string, args: string[]): Promise<Capture> {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn(command, args);
|
|
const start = Date.now();
|
|
const cap: Capture = { stdout: '', stderr: '', timeline: [], exitCode: null };
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
cap.stdout += chunk.toString();
|
|
cap.timeline.push({ stream: 'out', size: chunk.length, at: Date.now() - start });
|
|
});
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
cap.stderr += chunk.toString();
|
|
cap.timeline.push({ stream: 'err', size: chunk.length, at: Date.now() - start });
|
|
});
|
|
proc.on('close', (code) => {
|
|
cap.exitCode = code;
|
|
resolve(cap);
|
|
});
|
|
proc.on('error', reject);
|
|
});
|
|
}
|
|
|
|
const runCli = (args: string[]) => captureSubprocess('node', [cliPath, ...args]);
|
|
const runJar = (args: string[]) => captureSubprocess('java', ['-jar', jarPath, ...args]);
|
|
|
|
describe('CLI streaming contract', () => {
|
|
beforeAll(() => {
|
|
// Self-contained: build dist on demand so `pnpm test` works on a fresh
|
|
// checkout without a separate `pnpm build` step. The JAR has to come from
|
|
// the Maven build (it's not something Vitest should rebuild), so we still
|
|
// surface a clear error if it's missing.
|
|
if (!fs.existsSync(cliPath)) {
|
|
const result = spawnSync(
|
|
'pnpm',
|
|
['exec', 'tsup', '--no-dts', 'src/index.ts', 'src/cli.ts',
|
|
'--format', 'esm,cjs', '--shims', '--out-dir', 'dist'],
|
|
{ cwd: path.resolve(__dirname, '..'), stdio: 'inherit' },
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error('Failed to build dist for streaming integration test');
|
|
}
|
|
}
|
|
if (!fs.existsSync(jarPath)) {
|
|
throw new Error(
|
|
`Bundled JAR not found at ${jarPath} — build the Java module first ` +
|
|
`(\`mvn package\` in java/, then \`pnpm run setup\` in this package).`,
|
|
);
|
|
}
|
|
if (!fs.existsSync(samplePdf)) {
|
|
throw new Error(`Sample PDF not found at ${samplePdf}`);
|
|
}
|
|
}, 60000);
|
|
|
|
it('prints --to-stdout output exactly once (regression for #398)', async () => {
|
|
// Ground truth: invoke the JAR directly with --quiet so stderr is empty
|
|
// and stdout carries only the result payload. The Node CLI must produce
|
|
// the same bytes — no more, no less. Comparing against the JAR rather
|
|
// than another Node-CLI run prevents a symmetric-mutation regression
|
|
// (e.g., trimming/appending in both code paths) from sneaking past.
|
|
const referenceCap = await runJar([samplePdf, '--quiet', '--format', 'text', '--to-stdout']);
|
|
expect(referenceCap.exitCode).toBe(0);
|
|
const referenceStdout = referenceCap.stdout;
|
|
expect(referenceStdout.length).toBeGreaterThan(1000);
|
|
|
|
const cap = await runCli([samplePdf, '--format', 'text', '--to-stdout']);
|
|
expect(cap.exitCode).toBe(0);
|
|
|
|
// Defining check: the no-flag Node CLI must produce the *exact same*
|
|
// stdout bytes as the JAR. Pre-fix this came out at 2x because of
|
|
// double-write. Length-only comparison would miss byte-substituting
|
|
// mutations of identical length, so we compare the full payload.
|
|
expect(cap.stdout).toBe(referenceStdout);
|
|
}, 60000);
|
|
|
|
it('forwards Java progress logs to stderr in real time before stdout completes', async () => {
|
|
const cap = await runCli([samplePdf, '--format', 'text', '--to-stdout']);
|
|
expect(cap.exitCode).toBe(0);
|
|
|
|
// Java emits a "Number of pages" line during preprocessing — long before
|
|
// text extraction finishes — to stderr.
|
|
expect(cap.stderr).toMatch(/Number of pages/);
|
|
|
|
// The property that makes long runs observable: at least one stderr chunk
|
|
// arrives before stdout finishes streaming. We compare *event indices*
|
|
// (arrival order in cap.timeline) rather than millisecond timestamps to
|
|
// stay deterministic — two events scheduled in the same tick will share
|
|
// an `at` value but always have distinct indices.
|
|
const firstErrIdx = cap.timeline.findIndex((e) => e.stream === 'err');
|
|
let lastOutIdx = -1;
|
|
for (let i = cap.timeline.length - 1; i >= 0; i--) {
|
|
if (cap.timeline[i].stream === 'out') {
|
|
lastOutIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
expect(firstErrIdx).toBeGreaterThanOrEqual(0);
|
|
expect(lastOutIdx).toBeGreaterThanOrEqual(0);
|
|
expect(firstErrIdx).toBeLessThan(lastOutIdx);
|
|
}, 60000);
|
|
|
|
it('library convert() does not leak to the parent process stdio', async () => {
|
|
// Spawn a tiny Node script that imports convert() and calls it. We capture
|
|
// *that* process's stdio: convert() must not write to stdout/stderr itself.
|
|
// The import specifier is a file:// URL so the harness works on Windows
|
|
// (where path.resolve yields backslashes that ESM rejects).
|
|
const distIndexUrl = pathToFileURL(path.resolve(__dirname, '..', 'dist', 'index.js')).href;
|
|
const harness = `
|
|
import { convert } from '${distIndexUrl}';
|
|
const out = await convert(${JSON.stringify(samplePdf)}, {
|
|
format: ['text'], toStdout: true, quiet: true,
|
|
});
|
|
// One intended write at the end so we can distinguish "the marker"
|
|
// from any leakage caused by executeJar forwarding to process.stdout.
|
|
process.stdout.write('LEN=' + out.length + '\\n');
|
|
`;
|
|
|
|
// Place the harness in an isolated tmpdir — survives Ctrl-C / OOM cleanly
|
|
// (OS reaps tmpdir trees) instead of polluting the source tree.
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odl-pdf-issue398-'));
|
|
const tmpScript = path.join(tmpDir, 'harness.mjs');
|
|
fs.writeFileSync(tmpScript, harness);
|
|
try {
|
|
const cap = await captureSubprocess('node', [tmpScript]);
|
|
expect(cap.exitCode).toBe(0);
|
|
// The only line on stdout must be our LEN= marker. Anything else means
|
|
// executeJar leaked Java's stdout into the parent.
|
|
expect(cap.stdout.trim().split('\n')).toHaveLength(1);
|
|
expect(cap.stdout).toMatch(/^LEN=\d+\n$/);
|
|
expect(cap.stderr).toBe('');
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
});
|