From 28a9ce4c5c9e01e786045b9a4dbbe2dc5b9c0e5e Mon Sep 17 00:00:00 2001 From: teddiesloco Date: Sat, 15 Aug 2026 12:06:11 +0700 Subject: [PATCH] fix(filesystem): prevent off-by-one in tailFile when file ends with trailing newline When a file ends with a trailing newline (`\n` or `\r\n`), splitting the last chunk on newline produces an empty trailing element. `tailFile` was treating this empty element as a valid line, returning N-1 actual lines instead of N. This fix strips the trailing newline only from the very first chunk read (the end of the file) before splitting, matching the behavior of Unix `tail`. Also handles the edge case where the very first line of the file was previously dropped if the file was read to the beginning with `remainingText` left. Adds 7 real-fs regression tests covering trailing/no-trailing newline, CRLF, single lines, and multi-chunk files. --- .../__tests__/tail-head-real-fs.test.ts | 76 +++++++++++++++++++ src/filesystem/lib.ts | 21 ++++- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/filesystem/__tests__/tail-head-real-fs.test.ts diff --git a/src/filesystem/__tests__/tail-head-real-fs.test.ts b/src/filesystem/__tests__/tail-head-real-fs.test.ts new file mode 100644 index 0000000000..537421a5ac --- /dev/null +++ b/src/filesystem/__tests__/tail-head-real-fs.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { tailFile, headFile } from '../lib.js'; + +describe('tailFile and headFile (real filesystem)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tail-head-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('tailFile - trailing newline handling', () => { + it('returns the exact requested number of lines when file has a trailing newline', async () => { + const filePath = path.join(tmpDir, 'trailing.txt'); + await fs.writeFile(filePath, 'line1\nline2\nline3\n', 'utf-8'); + + const result = await tailFile(filePath, 2); + expect(result).toBe('line2\nline3'); + }); + + it('returns the exact requested number of lines when file has NO trailing newline', async () => { + const filePath = path.join(tmpDir, 'no-trailing.txt'); + await fs.writeFile(filePath, 'line1\nline2\nline3', 'utf-8'); + + const result = await tailFile(filePath, 2); + expect(result).toBe('line2\nline3'); + }); + + it('returns all lines if requested count exceeds total lines (with trailing newline)', async () => { + const filePath = path.join(tmpDir, 'all-trailing.txt'); + await fs.writeFile(filePath, 'line1\nline2\nline3\n', 'utf-8'); + + const result = await tailFile(filePath, 5); + expect(result).toBe('line1\nline2\nline3'); + }); + + it('handles CRLF line endings with trailing newline', async () => { + const filePath = path.join(tmpDir, 'crlf.txt'); + await fs.writeFile(filePath, 'line1\r\nline2\r\nline3\r\n', 'utf-8'); + + const result = await tailFile(filePath, 2); + expect(result).toBe('line2\nline3'); + }); + + it('handles single line without trailing newline', async () => { + const filePath = path.join(tmpDir, 'single.txt'); + await fs.writeFile(filePath, 'hello', 'utf-8'); + + const result = await tailFile(filePath, 1); + expect(result).toBe('hello'); + }); + + it('handles single line with trailing newline', async () => { + const filePath = path.join(tmpDir, 'single-nl.txt'); + await fs.writeFile(filePath, 'hello\n', 'utf-8'); + + const result = await tailFile(filePath, 1); + expect(result).toBe('hello'); + }); + + it('handles large file across chunk boundaries with trailing newline', async () => { + const filePath = path.join(tmpDir, 'large.txt'); + const lines = Array.from({ length: 200 }, (_, i) => `line_${String(i + 1).padStart(3, '0')}`); + await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf-8'); + + const result = await tailFile(filePath, 3); + expect(result).toBe('line_198\nline_199\nline_200'); + }); + }); +}); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..7581b2e4c0 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -297,6 +297,7 @@ export async function tailFile(filePath: string, numLines: number): Promise 0 && linesFound < numLines) { @@ -308,12 +309,23 @@ export async function tailFile(filePath: string, numLines: number): Promise 0) { remainingText = chunkLines[0]; @@ -327,6 +339,11 @@ export async function tailFile(filePath: string, numLines: number): Promise