fix(update): add line-buffering and unit tests for stderr filter

Address review feedback:
- Add line-buffering to handle chunk splitting edge case
- Add 14 unit tests for stderr filter logic
- Test chunk boundary handling, mixed output, edge cases

Handles case where "npm warn cleanup" could be split across chunks.
This commit is contained in:
kaitranntt
2026-01-30 07:43:38 -05:00
parent c9f8ed1a04
commit b39726fc07
2 changed files with 228 additions and 4 deletions
+16 -4
View File
@@ -218,12 +218,24 @@ async function performNpmUpdate(
// On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.)
// These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing
// Use line-buffering to handle chunk splitting (data events don't guarantee message boundaries)
if (isWindows && child.stderr) {
let stderrBuffer = '';
child.stderr.on('data', (data: Buffer) => {
const output = data.toString();
// Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds)
if (!/npm warn cleanup/i.test(output)) {
process.stderr.write(data);
stderrBuffer += data.toString();
const lines = stderrBuffer.split('\n');
stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
// Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds)
if (!/npm warn cleanup/i.test(line)) {
process.stderr.write(line + '\n');
}
}
});
child.stderr.on('close', () => {
// Flush remaining buffer on stream close
if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) {
process.stderr.write(stderrBuffer);
}
});
}
@@ -0,0 +1,212 @@
/**
* Unit Tests for Update Command stderr filtering on Windows
*
* Tests the npm cleanup warning filter logic that hides cosmetic EPERM
* warnings on Windows during `ccs update`. These warnings occur when
* npm fails to unlink native module prebuilds (bcrypt.node) due to
* antivirus/indexing file locking.
*
* @see https://github.com/kaitranntt/ccs/issues/405
*/
const assert = require('assert');
/**
* Simulates the line-buffered stderr filtering logic from update-command.ts
* This is extracted for testability since mocking spawn is complex.
*/
function createStderrFilter() {
let buffer = '';
const output = [];
return {
/**
* Process a chunk of stderr data (simulates 'data' event)
*/
processChunk(chunk) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!/npm warn cleanup/i.test(line)) {
output.push(line);
}
}
},
/**
* Flush remaining buffer (simulates 'close' event)
*/
flush() {
if (buffer && !/npm warn cleanup/i.test(buffer)) {
output.push(buffer);
}
buffer = '';
},
/**
* Get filtered output lines
*/
getOutput() {
return output;
},
/**
* Get remaining buffer (for testing)
*/
getBuffer() {
return buffer;
},
};
}
describe('Update Command stderr filter', function () {
describe('npm cleanup warning filtering', function () {
it('filters single npm warn cleanup line', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup Failed to remove some directories\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('filters multiple npm warn cleanup lines', function () {
const filter = createStderrFilter();
filter.processChunk(
"npm warn cleanup 'C:\\\\Users\\\\...\\\\node_modules\\\\@kaitranntt\\\\.ccs-VzVYv4mp',\n" +
"npm warn cleanup [Error: EPERM: operation not permitted, unlink '...\\\\bcrypt.node']\n" +
'npm warn cleanup errno: -4048,\n' +
"npm warn cleanup code: 'EPERM',\n" +
"npm warn cleanup syscall: 'unlink',\n"
);
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('preserves non-cleanup npm warnings', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn deprecated lodash@1.0.0: Use lodash@4.x instead\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm warn deprecated lodash@1.0.0: Use lodash@4.x instead']);
});
it('preserves npm errors', function () {
const filter = createStderrFilter();
filter.processChunk('npm ERR! code ENOTFOUND\n');
filter.processChunk('npm ERR! network request failed\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm ERR! code ENOTFOUND', 'npm ERR! network request failed']);
});
it('filters cleanup but preserves other warnings in mixed output', function () {
const filter = createStderrFilter();
filter.processChunk(
'npm warn deprecated chalk@4.0.0: Deprecated\n' +
'npm warn cleanup Failed to remove...\n' +
'npm warn peer react@17: Use react@18\n' +
'npm warn cleanup errno: -4048,\n'
);
filter.flush();
assert.deepStrictEqual(filter.getOutput(), [
'npm warn deprecated chalk@4.0.0: Deprecated',
'npm warn peer react@17: Use react@18',
]);
});
it('is case-insensitive for npm warn cleanup', function () {
const filter = createStderrFilter();
filter.processChunk('NPM WARN CLEANUP something\n');
filter.processChunk('Npm Warn Cleanup another\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
});
describe('chunk boundary handling', function () {
it('handles warning split across chunks', function () {
const filter = createStderrFilter();
// Simulate "npm warn cleanup" split across two chunks
filter.processChunk('npm war');
filter.processChunk('n cleanup Failed to remove\n');
filter.flush();
// Should still filter - line-buffering catches this
assert.deepStrictEqual(filter.getOutput(), []);
});
it('handles multiple lines split across chunks', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn deprecated lodash\n');
filter.processChunk('npm warn cleanup ');
filter.processChunk('EPERM error\n');
filter.processChunk('npm ERR! fatal\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm warn deprecated lodash', 'npm ERR! fatal']);
});
it('buffers incomplete line until newline', function () {
const filter = createStderrFilter();
filter.processChunk('incomplete line without newline');
// Nothing output yet - still buffered
assert.deepStrictEqual(filter.getOutput(), []);
assert.strictEqual(filter.getBuffer(), 'incomplete line without newline');
// Complete the line
filter.processChunk(' - now complete\n');
assert.deepStrictEqual(filter.getOutput(), ['incomplete line without newline - now complete']);
});
it('flushes remaining buffer on close', function () {
const filter = createStderrFilter();
filter.processChunk('final line no newline');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['final line no newline']);
});
it('does not flush cleanup warning on close', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup no newline');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
});
describe('edge cases', function () {
it('handles empty input', function () {
const filter = createStderrFilter();
filter.processChunk('');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('handles only newlines', function () {
const filter = createStderrFilter();
filter.processChunk('\n\n\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['', '', '']);
});
it('handles Windows CRLF line endings', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup EPERM\r\nnpm ERR! error\r\n');
filter.flush();
// Note: \r remains but line is still filtered by pattern
// The ERR line has \r at end but that's okay
const output = filter.getOutput();
assert.strictEqual(output.length, 1);
assert.ok(output[0].includes('npm ERR! error'));
});
});
});