Files
pxpipe/tests/export-collect.test.ts
Danil Silantyev 27e60d0c17 fix(export): apply include/exclude and size limit to --git untracked files
In --git mode, untracked files (git ls-files --others) were read straight to
memory with only a binary check — unlike directory and single-file modes, which
also apply --include/--exclude globs and the 1 MiB MAX_FILE_BYTES cap. So
`pxpipe export --git --include '*.ts'` still slurped every untracked file
regardless of the filter, and an untracked multi-MB file was read whole (a
resource-safety hazard).

- extract the shared gate into src/export-collect.ts: readExportTextFile()
  applies include/exclude → size → binary, in that order, and is import-safe
  (src/core/export.ts is contractually fs-free, so it can't live there)
- route all three collection paths (walkDir, single-file target, --git
  untracked) through it; untracked skips now warn like explicit targets do
- tests/export-collect.test.ts covers include, exclude, oversized, the size
  boundary, binary, and inaccessible

Verified end-to-end: `export --git --include '*.ts'` in a temp repo excludes an
untracked .md, skips an oversized .ts (with a warning), and keeps the .ts.
2026-07-04 19:06:35 -04:00

75 lines
3.1 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { readExportTextFile, looksLikeBinary, MAX_FILE_BYTES } from '../src/export-collect.js';
// readExportTextFile is the single gate now shared by every `pxpipe export`
// collection mode (directory walk, single-file target, and --git untracked).
// Before this, the --git untracked path applied none of these checks — it
// ignored --include/--exclude and read files of any size fully into memory.
describe('readExportTextFile — shared export file gate', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pxpipe-collect-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const write = (name: string, content: string | Buffer): string => {
const p = path.join(tmpDir, name);
fs.writeFileSync(p, content);
return p;
};
it('reads an included text file as utf8', () => {
const p = write('a.ts', 'export const x = 1;\n');
const r = readExportTextFile(p, 'a.ts', [], []);
expect(r.kind).toBe('ok');
if (r.kind === 'ok') expect(r.content).toBe('export const x = 1;\n');
});
it('respects --include: a .md is excluded when include is *.ts, the .ts sibling passes', () => {
const md = write('b.md', '# readme\n');
const ts = write('a.ts', 'const a = 1;\n');
expect(readExportTextFile(md, 'b.md', ['*.ts'], []).kind).toBe('excluded');
expect(readExportTextFile(ts, 'a.ts', ['*.ts'], []).kind).toBe('ok');
});
it('respects --exclude', () => {
const p = write('secret.ts', 'const s = 1;\n');
expect(readExportTextFile(p, 'secret.ts', [], ['secret.*']).kind).toBe('excluded');
});
it('skips an oversized file instead of reading it into memory', () => {
const p = write('big.ts', 'a'.repeat(MAX_FILE_BYTES + 1));
expect(readExportTextFile(p, 'big.ts', [], []).kind).toBe('oversized');
});
it('accepts a file exactly at the size limit', () => {
const p = write('edge.ts', 'a'.repeat(MAX_FILE_BYTES));
expect(readExportTextFile(p, 'edge.ts', [], []).kind).toBe('ok');
});
it('skips a binary file (null byte in first 512 bytes)', () => {
const p = write('bin.dat', Buffer.from([0x41, 0x00, 0x42]));
expect(readExportTextFile(p, 'bin.dat', [], []).kind).toBe('binary');
});
it('reports a missing/inaccessible file', () => {
expect(readExportTextFile(path.join(tmpDir, 'nope.ts'), 'nope.ts', [], []).kind).toBe('inaccessible');
});
it('applies the include/exclude filter before touching the filesystem', () => {
// A path that does not exist but is filtered out reports 'excluded', not
// 'inaccessible' — the glob gate short-circuits before any stat.
expect(readExportTextFile(path.join(tmpDir, 'ghost.md'), 'ghost.md', ['*.ts'], []).kind).toBe('excluded');
});
it('looksLikeBinary flags a null byte but passes plain text', () => {
expect(looksLikeBinary(Buffer.from('plain text'))).toBe(false);
expect(looksLikeBinary(Buffer.from([0x00]))).toBe(true);
});
});