From 3e98be3e3989fbcad72e7177dc461f0ddc20370b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 13:59:03 -0800 Subject: [PATCH] test: add Round 90 tests for readStdinJson timeout and saveAliases double failure - Test readStdinJson timeout path when stdin never closes (resolves with {}) - Test readStdinJson timeout path with partial invalid JSON (catch resolves with {}) - Test saveAliases backup restore double failure (inner restoreErr catch at line 135) Total tests: 830 --- tests/hooks/hooks.test.js | 59 +++++++++++++++++++++++++++++++ tests/lib/session-aliases.test.js | 49 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 51bbd144..8ca5be8e 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -3562,6 +3562,65 @@ Some random content without the expected ### Context to Load section cleanupTestDir(testDir); })) passed++; else failed++; + // ── Round 90: readStdinJson timeout path (utils.js lines 215-229) ── + console.log('\nRound 90: readStdinJson (timeout fires when stdin stays open):'); + + if (await asyncTest('readStdinJson resolves with {} when stdin never closes (timeout fires, no data)', async () => { + // utils.js line 215: setTimeout fires because stdin 'end' never arrives. + // Line 225: data.trim() is empty → resolves with {}. + // Exercises: removeAllListeners, process.stdin.unref(), and the empty-data timeout resolution. + const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; + return new Promise((resolve, reject) => { + const child = spawn('node', ['-e', script], { + cwd: path.resolve(__dirname, '..', '..'), + stdio: ['pipe', 'pipe', 'pipe'] + }); + // Don't write anything or close stdin — force the timeout to fire + let stdout = ''; + child.stdout.on('data', d => stdout += d); + const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000); + child.on('close', (code) => { + clearTimeout(timer); + try { + assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); + const parsed = JSON.parse(stdout); + assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when no data received before timeout'); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + })) passed++; else failed++; + + if (await asyncTest('readStdinJson resolves with {} when timeout fires with invalid partial JSON', async () => { + // utils.js lines 224-228: setTimeout fires, data.trim() is non-empty, + // JSON.parse(data) throws → catch at line 226 resolves with {}. + const script = 'const u=require("./scripts/lib/utils");u.readStdinJson({timeoutMs:100}).then(d=>{process.stdout.write(JSON.stringify(d));process.exit(0)})'; + return new Promise((resolve, reject) => { + const child = spawn('node', ['-e', script], { + cwd: path.resolve(__dirname, '..', '..'), + stdio: ['pipe', 'pipe', 'pipe'] + }); + // Write partial invalid JSON but don't close stdin — timeout fires with unparseable data + child.stdin.write('{"incomplete":'); + let stdout = ''; + child.stdout.on('data', d => stdout += d); + const timer = setTimeout(() => { child.kill(); reject(new Error('Test timed out')); }, 5000); + child.on('close', (code) => { + clearTimeout(timer); + try { + assert.strictEqual(code, 0, 'Should exit 0 via timeout resolution'); + const parsed = JSON.parse(stdout); + assert.deepStrictEqual(parsed, {}, 'Should resolve with {} when partial JSON cannot be parsed'); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + })) passed++; else failed++; + // Summary console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`); diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index d59456c4..f3a08842 100644 --- a/tests/lib/session-aliases.test.js +++ b/tests/lib/session-aliases.test.js @@ -1223,6 +1223,55 @@ function runTests() { resetAliases(); })) passed++; else failed++; + // ── Round 90: saveAliases backup restore double failure (inner catch restoreErr) ── + console.log('\nRound 90: saveAliases (backup restore double failure):'); + + if (test('saveAliases triggers inner restoreErr catch when both save and restore fail', () => { + // session-aliases.js lines 131-137: When saveAliases fails (outer catch), + // it tries to restore from backup. If the restore ALSO fails, the inner + // catch at line 135 logs restoreErr. No existing test creates this double-fault. + if (process.platform === 'win32') { + console.log(' (skipped — chmod not reliable on Windows)'); + return; + } + const isoHome = path.join(os.tmpdir(), `ecc-r90-restore-fail-${Date.now()}`); + const claudeDir = path.join(isoHome, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + // Pre-create a backup file while directory is still writable + const backupPath = path.join(claudeDir, 'session-aliases.json.bak'); + fs.writeFileSync(backupPath, JSON.stringify({ aliases: {}, version: '1.0' })); + + // Make .claude directory read-only (0o555): + // 1. writeFileSync(tempPath) → EACCES (can't create file in read-only dir) — outer catch + // 2. copyFileSync(backupPath, aliasesPath) → EACCES (can't create target) — inner catch (line 135) + fs.chmodSync(claudeDir, 0o555); + + const origH = process.env.HOME; + const origP = process.env.USERPROFILE; + process.env.HOME = isoHome; + process.env.USERPROFILE = isoHome; + + try { + delete require.cache[require.resolve('../../scripts/lib/session-aliases')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const freshAliases = require('../../scripts/lib/session-aliases'); + + const result = freshAliases.saveAliases({ aliases: { x: 1 }, version: '1.0' }); + assert.strictEqual(result, false, 'Should return false when save fails'); + + // Backup should still exist (restore also failed, so backup was not consumed) + assert.ok(fs.existsSync(backupPath), 'Backup should still exist after double failure'); + } finally { + process.env.HOME = origH; + process.env.USERPROFILE = origP; + delete require.cache[require.resolve('../../scripts/lib/session-aliases')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + try { fs.chmodSync(claudeDir, 0o755); } catch { /* best-effort */ } + fs.rmSync(isoHome, { recursive: true, force: true }); + } + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);