Skip to content
Draft
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
9 changes: 8 additions & 1 deletion src/core/processRunner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn } from 'child_process';
import * as path from 'path';

export interface ProcessOptions {
command: string;
Expand All @@ -20,12 +21,18 @@ export interface ProcessResult {
export async function runProcess(options: ProcessOptions): Promise<ProcessResult> {
const { command, args, cwd, timeoutMs, signal, onStdout, onStderr } = options;

// Normalize and validate: only allow absolute paths to prevent path traversal.
const resolvedCommand = path.resolve(command);
if (!path.isAbsolute(resolvedCommand)) {
return { exitCode: 1, stdout: '', stderr: `Invalid command path: ${command}`, timedOut: false };
}

return new Promise((resolve) => {
const stdout: string[] = [];
const stderr: string[] = [];
let timedOut = false;

const proc = spawn(command, args, { cwd, shell: false });
const proc = spawn(resolvedCommand, args, { cwd, shell: false });

const timer = setTimeout(() => {
timedOut = true;
Expand Down
42 changes: 42 additions & 0 deletions src/test/unit/bundleManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getBundleCacheDir, ensureBundleCacheDir } from '../../runtime/bundleManager';

let tmpStorage: string;

beforeAll(() => {
tmpStorage = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-manager-test-'));
});

afterAll(() => {
fs.rmSync(tmpStorage, { recursive: true, force: true });
});

describe('bundleManager', () => {
it('getBundleCacheDir returns storagePath/bundle-cache', () => {
expect(getBundleCacheDir(tmpStorage)).toBe(path.join(tmpStorage, 'bundle-cache'));
});

it('ensureBundleCacheDir creates the directory if it does not exist', () => {
const dir = ensureBundleCacheDir(tmpStorage);
expect(fs.existsSync(dir)).toBe(true);
});

it('ensureBundleCacheDir returns the bundle cache directory path', () => {
const dir = ensureBundleCacheDir(tmpStorage);
expect(dir).toBe(path.join(tmpStorage, 'bundle-cache'));
});

it('ensureBundleCacheDir is idempotent (safe to call multiple times)', () => {
expect(() => ensureBundleCacheDir(tmpStorage)).not.toThrow();
expect(() => ensureBundleCacheDir(tmpStorage)).not.toThrow();
expect(fs.existsSync(getBundleCacheDir(tmpStorage))).toBe(true);
});

it('getBundleCacheDir uses the provided storage path', () => {
const custom = '/some/other/path';
expect(getBundleCacheDir(custom)).toBe(path.join(custom, 'bundle-cache'));
});
});
136 changes: 136 additions & 0 deletions src/test/unit/compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'path';

// Mock processRunner before importing compiler
vi.mock('../../core/processRunner', () => ({
runProcess: vi.fn(),
}));

import { compile } from '../../core/compiler';
import { runProcess } from '../../core/processRunner';

const mockRunProcess = vi.mocked(runProcess);

const BASE_OPTIONS = {
binaryPath: '/usr/local/bin/tectonic',
mainFile: '/workspace/main.tex',
outputDirectory: 'out',
workspaceRoot: '/workspace',
timeoutMs: 60000,
offlineOnly: false,
};

beforeEach(() => {
vi.clearAllMocks();
});

describe('compiler', () => {
it('calls runProcess with correct arguments for a basic compile', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });

await compile(BASE_OPTIONS);

expect(mockRunProcess).toHaveBeenCalledOnce();
const call = mockRunProcess.mock.calls[0][0];
expect(call.command).toBe('/usr/local/bin/tectonic');
expect(call.args).toContain('compile');
expect(call.args).toContain('--outdir');
expect(call.args).toContain('out');
expect(call.args).toContain('/workspace/main.tex');
expect(call.cwd).toBe('/workspace');
expect(call.timeoutMs).toBe(60000);
});

it('adds --only-cached flag when offlineOnly is true', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });

await compile({ ...BASE_OPTIONS, offlineOnly: true });

const call = mockRunProcess.mock.calls[0][0];
expect(call.args).toContain('--only-cached');
});

it('does not add --only-cached flag when offlineOnly is false', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });

await compile({ ...BASE_OPTIONS, offlineOnly: false });

const call = mockRunProcess.mock.calls[0][0];
expect(call.args).not.toContain('--only-cached');
});

it('returns success=true and outputPdf when exit code is 0', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: 'Output written.', stderr: '', timedOut: false });

const result = await compile(BASE_OPTIONS);

expect(result.success).toBe(true);
expect(result.outputPdf).toBe(path.join('/workspace', 'out', 'main.pdf'));
expect(result.timedOut).toBe(false);
});

it('returns success=false and no outputPdf when exit code is non-zero', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 1, stdout: '', stderr: '! LaTeX Error: bad.', timedOut: false });

const result = await compile(BASE_OPTIONS);

expect(result.success).toBe(false);
expect(result.outputPdf).toBeUndefined();
});

it('returns timedOut=true when process times out', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 1, stdout: '', stderr: '', timedOut: true });

const result = await compile(BASE_OPTIONS);

expect(result.timedOut).toBe(true);
expect(result.success).toBe(false);
});

it('parses log entries from combined stdout+stderr', async () => {
const stderr = `! LaTeX Error: File \`missing.sty' not found.\nl.3 \\usepackage{missing}`;
mockRunProcess.mockResolvedValue({ exitCode: 1, stdout: '', stderr, timedOut: false });

const result = await compile(BASE_OPTIONS);

expect(result.logs.length).toBeGreaterThan(0);
expect(result.logs[0].severity).toBe('error');
});

it('includes durationMs in result', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });

const result = await compile(BASE_OPTIONS);

expect(result.durationMs).toBeGreaterThanOrEqual(0);
});

it('passes AbortSignal through to runProcess', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });
const controller = new AbortController();

await compile({ ...BASE_OPTIONS, signal: controller.signal });

const call = mockRunProcess.mock.calls[0][0];
expect(call.signal).toBe(controller.signal);
});

it('passes onOutput callback as both onStdout and onStderr', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });
const onOutput = vi.fn();

await compile({ ...BASE_OPTIONS, onOutput });

const call = mockRunProcess.mock.calls[0][0];
expect(call.onStdout).toBe(onOutput);
expect(call.onStderr).toBe(onOutput);
});

it('derives PDF name from main tex file basename', async () => {
mockRunProcess.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', timedOut: false });

const result = await compile({ ...BASE_OPTIONS, mainFile: '/workspace/thesis.tex' });

expect(result.outputPdf).toBe(path.join('/workspace', 'out', 'thesis.pdf'));
});
});
59 changes: 59 additions & 0 deletions src/test/unit/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { DEFAULTS } from '../../config/defaults';

describe('config/defaults', () => {
it('autoCompileOnSave defaults to false', () => {
expect(DEFAULTS.autoCompileOnSave).toBe(false);
});

it('compileDebounceMs defaults to 1000', () => {
expect(DEFAULTS.compileDebounceMs).toBe(1000);
});

it('mainFile defaults to empty string', () => {
expect(DEFAULTS.mainFile).toBe('');
});

it('outputDirectory defaults to "out"', () => {
expect(DEFAULTS.outputDirectory).toBe('out');
});

it('runtimeChannel defaults to "stable"', () => {
expect(DEFAULTS.runtimeChannel).toBe('stable');
});

it('offlineOnly defaults to false', () => {
expect(DEFAULTS.offlineOnly).toBe(false);
});

it('compileTimeoutSec defaults to 60', () => {
expect(DEFAULTS.compileTimeoutSec).toBe(60);
});

it('telemetryEnabled defaults to false', () => {
expect(DEFAULTS.telemetryEnabled).toBe(false);
});

it('previewAutoOpen defaults to true', () => {
expect(DEFAULTS.previewAutoOpen).toBe(true);
});

it('previewPreserveFocus defaults to true', () => {
expect(DEFAULTS.previewPreserveFocus).toBe(true);
});

it('logsVerbosity defaults to "normal"', () => {
expect(DEFAULTS.logsVerbosity).toBe('normal');
});

it('all required keys are present', () => {
const keys = [
'autoCompileOnSave', 'compileDebounceMs', 'mainFile', 'outputDirectory',
'runtimeChannel', 'offlineOnly', 'compileTimeoutSec', 'telemetryEnabled',
'previewAutoOpen', 'previewPreserveFocus', 'logsVerbosity',
];
for (const key of keys) {
expect(DEFAULTS).toHaveProperty(key);
}
});
});
Loading