Skip to content
Merged
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
142 changes: 141 additions & 1 deletion __tests__/server-utils.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, test, expect } from 'bun:test';
import { EventEmitter } from 'events';
import path from 'path';
import { PassThrough } from 'stream';
import {
getX2TFormatCode,
getOutputFormatInfo,
Expand All @@ -9,7 +12,11 @@ import {
generateX2TConfig,
extractFilePathFromUrl,
isXLSXSignature,
classifySendFileError
classifySendFileError,
resolveX2TPath,
describeChildExit,
getBufferedStderrLog,
runChildProcess
} from '../server-utils.js';

describe('getX2TFormatCode', () => {
Expand Down Expand Up @@ -348,3 +355,136 @@ describe('classifySendFileError', () => {
});
});
});

describe('resolveX2TPath', () => {
test('prefers x2t.exe on Windows when present', () => {
const expectedExePath = path.join('/app', 'converter', 'x2t.exe');
const expectedFallbackPath = path.join('/app', 'converter', 'x2t');
const result = resolveX2TPath('/app', {
platform: 'win32',
pathExists: (candidate) => candidate === expectedExePath
});

expect(result.path).toBe(expectedExePath);
expect(result.candidates).toEqual([expectedExePath, expectedFallbackPath]);
});

test('falls back to extensionless x2t on Windows', () => {
const expectedPath = path.join('/app', 'converter', 'x2t');
const result = resolveX2TPath('/app', {
platform: 'win32',
pathExists: (candidate) => candidate === expectedPath
});

expect(result.path).toBe(expectedPath);
});

test('uses extensionless x2t on non-Windows platforms', () => {
const expectedPath = path.join('/app', 'converter', 'x2t');
const result = resolveX2TPath('/app', {
platform: 'darwin',
pathExists: () => true
});

expect(result.path).toBe(expectedPath);
expect(result.candidates).toEqual([expectedPath]);
});
});

describe('describeChildExit', () => {
test('describes signal exits', () => {
expect(describeChildExit(null, 'SIGTERM')).toBe('signal SIGTERM');
});

test('describes numeric exit codes', () => {
expect(describeChildExit(1, null)).toBe('code 1');
expect(describeChildExit(0, null)).toBe('code 0');
});

test('handles missing exit details', () => {
expect(describeChildExit(undefined, undefined)).toBe('unknown exit status');
});
});

describe('getBufferedStderrLog', () => {
test('returns the full stderr buffer as an error for non-zero exits', () => {
expect(getBufferedStderrLog({
code: 1,
signal: null,
stderr: 'first line\nsecond line\n'
})).toEqual({
level: 'error',
output: 'first line\nsecond line'
});
});

test('returns info for successful exits with stderr output', () => {
expect(getBufferedStderrLog({
code: 0,
signal: null,
stderr: 'warning\n'
})).toEqual({
level: 'info',
output: 'warning'
});
});
});

describe('runChildProcess', () => {
function createChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
return child;
}

test('captures stdout, stderr, exit code, and signal', async () => {
const child = createChild();
const stdoutChunks = [];
const stderrChunks = [];
const promise = runChildProcess(() => child, 'x2t', ['params.xml'], {
onStdout: (chunk) => stdoutChunks.push(chunk),
onStderr: (chunk) => stderrChunks.push(chunk)
});

child.stdout.write('hello ');
child.stderr.write('oops');
child.stdout.end('world');
child.emit('close', 0, null);

await expect(promise).resolves.toEqual({
code: 0,
signal: null,
stdout: 'hello world',
stderr: 'oops'
});
expect(stdoutChunks).toEqual(['hello ', 'world']);
expect(stderrChunks).toEqual(['oops']);
});

test('rejects with captured output when the child emits an error', async () => {
const child = createChild();
const promise = runChildProcess(() => child, 'x2t', ['params.xml']);

child.stderr.write('missing dll');
child.emit('error', new Error('spawn EPERM'));

await expect(promise).rejects.toMatchObject({
error: expect.any(Error),
stdout: '',
stderr: 'missing dll'
});
});

test('rejects when spawn throws synchronously', async () => {
const promise = runChildProcess(() => {
throw new Error('missing binary');
}, 'x2t', ['params.xml']);

await expect(promise).rejects.toMatchObject({
error: expect.any(Error),
stdout: '',
stderr: ''
});
});
});
136 changes: 135 additions & 1 deletion server-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,136 @@ function classifySendFileError(err, options) {
};
}

/**
* Resolve the x2t binary path for the current platform.
* On Windows we prefer x2t.exe but tolerate legacy packaging that omits the extension.
* @param {string} rootDir
* @param {object} [options]
* @param {string} [options.platform]
* @param {(filepath: string) => boolean} [options.pathExists]
* @returns {{ path: string, candidates: string[] }}
*/
function resolveX2TPath(rootDir, options = {}) {
const platform = options.platform || process.platform;
const pathExists = options.pathExists || (() => true);
const candidates = platform === 'win32'
? [
path.join(rootDir, 'converter', 'x2t.exe'),
path.join(rootDir, 'converter', 'x2t')
]
: [path.join(rootDir, 'converter', 'x2t')];

return {
path: candidates.find((candidate) => pathExists(candidate)) || candidates[0],
candidates
};
}

/**
* Convert a child-process close event into a short human-readable description.
* @param {number|null|undefined} code
* @param {NodeJS.Signals|string|null|undefined} signal
* @returns {string}
*/
function describeChildExit(code, signal) {
if (signal) {
return `signal ${signal}`;
}

if (Number.isInteger(code)) {
return `code ${code}`;
}

return 'unknown exit status';
}

/**
* Decide how a completed child process stderr buffer should be logged.
* We wait until exit so successful tools that write noisy diagnostics to stderr
* do not get treated like failures, while failed exits still preserve the full
* stderr buffer in one log entry for debugging/Sentry.
* @param {{code: number|null, signal: NodeJS.Signals|string|null, stderr: string}} result
* @returns {{level: 'info' | 'error', output: string} | null}
*/
function getBufferedStderrLog(result) {
const output = result?.stderr?.trim();

if (!output) {
return null;
}

if (result.code === 0 && !result.signal) {
return { level: 'info', output };
}

return { level: 'error', output };
}

/**
* Run a spawned child process and capture its output without leaving unhandled
* error events behind.
* @param {(command: string, args: string[]) => import('child_process').ChildProcess} spawnFn
* @param {string} command
* @param {string[]} args
* @param {object} [options]
* @param {(chunk: string) => void} [options.onStdout]
* @param {(chunk: string) => void} [options.onStderr]
* @returns {Promise<{code: number|null, signal: NodeJS.Signals|null, stdout: string, stderr: string}>}
*/
function runChildProcess(spawnFn, command, args, options = {}) {
const { onStdout, onStderr } = options;

return new Promise((resolve, reject) => {
let child;

try {
child = spawnFn(command, args);
} catch (error) {
reject({ error, stdout: '', stderr: '' });
return;
}

let stdout = '';
let stderr = '';
let settled = false;

const finishResolve = (code, signal) => {
if (settled) return;
settled = true;
resolve({ code, signal, stdout, stderr });
};

const finishReject = (error) => {
if (settled) return;
settled = true;
reject({ error, stdout, stderr });
};

if (child.stdout && typeof child.stdout.on === 'function') {
child.stdout.on('data', (data) => {
const output = data.toString();
stdout += output;
if (typeof onStdout === 'function') {
onStdout(output);
}
});
}

if (child.stderr && typeof child.stderr.on === 'function') {
child.stderr.on('data', (data) => {
const output = data.toString();
stderr += output;
if (typeof onStderr === 'function') {
onStderr(output);
}
});
}

child.once('error', finishReject);
child.once('close', finishResolve);
});
}

module.exports = {
getX2TFormatCode,
getOutputFormatInfo,
Expand All @@ -250,5 +380,9 @@ module.exports = {
generateX2TConfig,
extractFilePathFromUrl,
isXLSXSignature,
classifySendFileError
classifySendFileError,
resolveX2TPath,
describeChildExit,
getBufferedStderrLog,
runChildProcess
};
Loading
Loading