Files
2026-07-13 12:59:42 +08:00

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);
});