feat: add streamArchiveEntry to archiveReader and tests
- Añade `streamArchiveEntry` que devuelve un stream para entradas internas de ZIP/7z - Añade tests unitarios que mockean `child_process.spawn` (7z + unzip fallback) - Mantiene `listArchiveEntries` y documenta dependencia de binarios en CI
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
* las llamadas a `child_process.exec`.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { exec, spawn } from 'child_process';
|
||||
|
||||
export type ArchiveEntry = { name: string; size: number };
|
||||
|
||||
@@ -86,4 +86,92 @@ export async function listArchiveEntries(
|
||||
}
|
||||
}
|
||||
|
||||
export default { listArchiveEntries };
|
||||
export async function streamArchiveEntry(
|
||||
filePath: string,
|
||||
entryPath: string,
|
||||
logger: { warn?: (...args: any[]) => void } = console
|
||||
): Promise<NodeJS.ReadableStream | null> {
|
||||
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
|
||||
|
||||
if (!['zip', '7z'].includes(ext)) return null;
|
||||
|
||||
const waitForStreamOrError = (proc: any): Promise<NodeJS.ReadableStream | null> =>
|
||||
new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const onProcError = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onStdoutError = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onData = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
proc.removeListener('error', onProcError);
|
||||
} catch (e) {}
|
||||
if (proc.stdout && proc.stdout.removeListener) {
|
||||
try {
|
||||
proc.stdout.removeListener('error', onStdoutError);
|
||||
proc.stdout.removeListener('readable', onData);
|
||||
proc.stdout.removeListener('data', onData);
|
||||
} catch (e) {}
|
||||
}
|
||||
resolve(proc.stdout);
|
||||
};
|
||||
|
||||
proc.once('error', onProcError);
|
||||
if (proc.stdout && proc.stdout.once) {
|
||||
proc.stdout.once('error', onStdoutError);
|
||||
proc.stdout.once('readable', onData);
|
||||
proc.stdout.once('data', onData);
|
||||
} else {
|
||||
// no stdout available
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
proc.once('close', () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Try 7z first
|
||||
try {
|
||||
let proc: any;
|
||||
try {
|
||||
proc = spawn('7z', ['x', '-so', filePath, entryPath]);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const stream = await waitForStreamOrError(proc);
|
||||
if (stream) return stream;
|
||||
} catch (err) {
|
||||
logger.warn?.({ err, filePath }, 'archiveReader: 7z spawn failed');
|
||||
}
|
||||
|
||||
// Fallback for zip
|
||||
if (ext === 'zip') {
|
||||
try {
|
||||
const proc2: any = spawn('unzip', ['-p', filePath, entryPath]);
|
||||
const stream2 = await waitForStreamOrError(proc2);
|
||||
if (stream2) return stream2;
|
||||
} catch (err2) {
|
||||
logger.warn?.({ err2, filePath }, 'archiveReader: unzip spawn failed');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default { listArchiveEntries, streamArchiveEntry };
|
||||
|
||||
69
backend/tests/services/archiveReader.stream.spec.ts
Normal file
69
backend/tests/services/archiveReader.stream.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { PassThrough } from 'stream';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: vi.fn() }));
|
||||
import * as child_process from 'child_process';
|
||||
import { streamArchiveEntry } from '../../src/services/archiveReader';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('services/archiveReader streamArchiveEntry', () => {
|
||||
it('streams entry using 7z stdout', async () => {
|
||||
const pass = new PassThrough();
|
||||
const proc = new EventEmitter() as any;
|
||||
proc.stdout = pass;
|
||||
|
||||
(child_process.spawn as any).mockImplementation(() => proc as any);
|
||||
|
||||
// Emular producción de datos de forma asíncrona
|
||||
setImmediate(() => {
|
||||
pass.write(Buffer.from('content-from-7z'));
|
||||
pass.end();
|
||||
});
|
||||
|
||||
const stream = await streamArchiveEntry('/roms/archive.7z', 'path/file.txt');
|
||||
expect(stream).not.toBeNull();
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream as any) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('content-from-7z');
|
||||
});
|
||||
|
||||
it('falls back to unzip -p when 7z throws', async () => {
|
||||
const pass = new PassThrough();
|
||||
const proc2 = new EventEmitter() as any;
|
||||
proc2.stdout = pass;
|
||||
|
||||
(child_process.spawn as any)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('spawn ENOENT');
|
||||
})
|
||||
.mockImplementationOnce(() => proc2 as any);
|
||||
|
||||
setImmediate(() => {
|
||||
pass.write(Buffer.from('fallback-content'));
|
||||
pass.end();
|
||||
});
|
||||
|
||||
const stream = await streamArchiveEntry('/roms/archive.zip', 'file.dat');
|
||||
expect(stream).not.toBeNull();
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream as any) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
expect(Buffer.concat(chunks).toString()).toBe('fallback-content');
|
||||
});
|
||||
|
||||
it('returns null for unsupported formats', async () => {
|
||||
const res = await streamArchiveEntry('/roms/archive.bin', 'entry');
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user