|
|
@@ -1,4 +1,4 @@
|
|
|
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
|
import * as fs from 'node:fs';
|
|
|
import * as os from 'node:os';
|
|
|
import * as path from 'node:path';
|
|
|
@@ -30,6 +30,180 @@ describe('logger', () => {
|
|
|
expect(fs.readdirSync(tmpDir).length).toBe(0);
|
|
|
});
|
|
|
|
|
|
+ test('falls back to stderr when logger initialization cannot create directory', async () => {
|
|
|
+ const blockedLogDir = path.join(tmpDir, 'not-a-directory');
|
|
|
+ fs.writeFileSync(blockedLogDir, 'not a directory');
|
|
|
+ process.env.OPENCODE_LOG_DIR = blockedLogDir;
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
|
+
|
|
|
+ try {
|
|
|
+ initLogger('session1');
|
|
|
+ log('fallback message');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('falling back to stderr'),
|
|
|
+ );
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('fallback message'),
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('falls back to stderr when the log file path is a directory', async () => {
|
|
|
+ const logDir = path.join(tmpDir, 'log-dir');
|
|
|
+ const logFilePath = path.join(logDir, 'oh-my-opencode-slim.session1.log');
|
|
|
+ fs.mkdirSync(logFilePath, { recursive: true });
|
|
|
+ process.env.OPENCODE_LOG_DIR = logDir;
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
|
+
|
|
|
+ try {
|
|
|
+ expect(() => initLogger('session1')).not.toThrow();
|
|
|
+ log('open failure fallback message');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('falling back to stderr'),
|
|
|
+ );
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('open failure fallback message'),
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('falls back to stderr when appending a log entry fails', async () => {
|
|
|
+ const logDir = path.join(tmpDir, 'log-dir');
|
|
|
+ process.env.OPENCODE_LOG_DIR = logDir;
|
|
|
+ initLogger('session1');
|
|
|
+ fs.rmSync(logDir, { recursive: true, force: true });
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
|
+
|
|
|
+ try {
|
|
|
+ log('failed file write');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+ log('subsequent fallback message');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ const fallbackWarnings = errorSpy.mock.calls.filter(
|
|
|
+ ([message]) =>
|
|
|
+ typeof message === 'string' &&
|
|
|
+ message.includes('falling back to stderr'),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(fallbackWarnings).toHaveLength(1);
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('failed file write'),
|
|
|
+ );
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('subsequent fallback message'),
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('warns once when multiple queued writes fail', async () => {
|
|
|
+ const logDir = path.join(tmpDir, 'log-dir');
|
|
|
+ process.env.OPENCODE_LOG_DIR = logDir;
|
|
|
+ initLogger('session1');
|
|
|
+ fs.rmSync(logDir, { recursive: true, force: true });
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
|
+
|
|
|
+ try {
|
|
|
+ log('first queued failure');
|
|
|
+ log('second queued failure');
|
|
|
+ log('third queued failure');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ const fallbackWarnings = errorSpy.mock.calls.filter(
|
|
|
+ ([message]) =>
|
|
|
+ typeof message === 'string' &&
|
|
|
+ message.includes('falling back to stderr'),
|
|
|
+ );
|
|
|
+ expect(fallbackWarnings).toHaveLength(1);
|
|
|
+
|
|
|
+ for (const message of [
|
|
|
+ 'first queued failure',
|
|
|
+ 'second queued failure',
|
|
|
+ 'third queued failure',
|
|
|
+ ]) {
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message));
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('does not let a stale failed write replace a newer file sink', async () => {
|
|
|
+ const oldLogDir = fs.mkdtempSync(path.join(tmpDir, 'old-log-'));
|
|
|
+ const newLogDir = fs.mkdtempSync(path.join(tmpDir, 'new-log-'));
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
|
+
|
|
|
+ try {
|
|
|
+ process.env.OPENCODE_LOG_DIR = oldLogDir;
|
|
|
+ initLogger('old');
|
|
|
+ log('stale message');
|
|
|
+ fs.rmSync(oldLogDir, { recursive: true, force: true });
|
|
|
+
|
|
|
+ process.env.OPENCODE_LOG_DIR = newLogDir;
|
|
|
+ initLogger('new');
|
|
|
+ log('new message');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ log('after stale failure');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ const newLogFile = path.join(newLogDir, 'oh-my-opencode-slim.new.log');
|
|
|
+ const content = fs.readFileSync(newLogFile, 'utf-8');
|
|
|
+ expect(content).toContain('new message');
|
|
|
+ expect(content).toContain('after stale failure');
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('stale message'),
|
|
|
+ );
|
|
|
+
|
|
|
+ const fallbackWarnings = errorSpy.mock.calls.filter(
|
|
|
+ ([message]) =>
|
|
|
+ typeof message === 'string' &&
|
|
|
+ message.includes('falling back to stderr'),
|
|
|
+ );
|
|
|
+ expect(fallbackWarnings).toHaveLength(0);
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ test('keeps logging best-effort when stderr fallback throws', async () => {
|
|
|
+ const logDir = path.join(tmpDir, 'log-dir');
|
|
|
+ process.env.OPENCODE_LOG_DIR = logDir;
|
|
|
+ initLogger('session1');
|
|
|
+ fs.rmSync(logDir, { recursive: true, force: true });
|
|
|
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {
|
|
|
+ throw new Error('stderr unavailable');
|
|
|
+ });
|
|
|
+
|
|
|
+ try {
|
|
|
+ expect(() => log('first failed write')).not.toThrow();
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ expect(() => log('second failed write')).not.toThrow();
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ errorSpy.mockImplementation(() => {});
|
|
|
+ log('after stderr failure');
|
|
|
+ await flushLoggerForTesting();
|
|
|
+
|
|
|
+ expect(errorSpy).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining('after stderr failure'),
|
|
|
+ );
|
|
|
+ } finally {
|
|
|
+ errorSpy.mockRestore();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
test('initLogger creates per-session log file', () => {
|
|
|
initLogger('20260416T143052');
|
|
|
log('test message');
|