mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
c54a49383a
The state file is now loaded into memory and stored within ClearcutLogger. At each tool call it checks whether it's been more than one day since the recorded value in the state file, and if so, log the last tool active, and update the state file. Regarding the state file's format, lastToolCall is not exactly the very last one, but rather the last one that was **logged**. This is the same with the existing lastActive timestamp (i.e. it's not the last timestamp when the server is active, but rather the last timestamp when the active status is logged). So here we keep the definitions consistent. A caveat for future improvement: arguably the loadState() and writeState() and everything in between should be a atomic unit (there might be multiple mcp servers running), but I think it's probably good enough for now. If we see weirdness we can make that happen. I also added format guards to loadState() so we don't have to try and catch everywhere else when de-serializing the string. We should actually just deserialize in loadState() but let me do this in a follow-up CL.
184 lines
6.1 KiB
TypeScript
184 lines
6.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {describe, it, afterEach, beforeEach} from 'node:test';
|
|
|
|
import sinon from 'sinon';
|
|
|
|
import {ClearcutLogger} from '../../src/telemetry/ClearcutLogger.js';
|
|
import {ErrorCode} from '../../src/telemetry/errors.js';
|
|
import * as persistence from '../../src/telemetry/persistence.js';
|
|
import {WatchdogClient} from '../../src/telemetry/WatchdogClient.js';
|
|
|
|
describe('FilePersistence', () => {
|
|
let tmpDir: string;
|
|
let logServerErrorStub: sinon.SinonStub;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = path.join(
|
|
await fs.realpath(os.tmpdir()),
|
|
`telemetry-test-${crypto.randomUUID()}`,
|
|
);
|
|
await fs.mkdir(tmpDir, {recursive: true});
|
|
|
|
ClearcutLogger.resetForTesting();
|
|
const mockWatchdog = sinon.createStubInstance(WatchdogClient);
|
|
const logger = ClearcutLogger.initialize({
|
|
appVersion: '1.0.0',
|
|
persistence: new persistence.FilePersistence(tmpDir),
|
|
watchdogClient: mockWatchdog,
|
|
});
|
|
logServerErrorStub = sinon.stub(logger, 'logServerError');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
sinon.restore();
|
|
ClearcutLogger.resetForTesting();
|
|
await fs.rm(tmpDir, {recursive: true, force: true});
|
|
});
|
|
|
|
describe('loadState', () => {
|
|
it('returns default state and does NOT log telemetry if file does not exist (ENOENT)', async () => {
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.notCalled(logServerErrorStub);
|
|
});
|
|
|
|
it('returns default state and LOGS telemetry if load fails due to corruption', async () => {
|
|
const filePath = path.join(tmpDir, 'telemetry_state.json');
|
|
await fs.writeFile(filePath, 'not-valid-json', 'utf-8');
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.calledOnceWithExactly(logServerErrorStub, {
|
|
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
|
|
});
|
|
});
|
|
|
|
it('returns default state and LOGS telemetry if load fails during read stage', async () => {
|
|
const filePath = path.join(tmpDir, 'telemetry_state.json');
|
|
await fs.writeFile(filePath, '{"valid": "json"}', 'utf-8');
|
|
|
|
const readFileStub = sinon
|
|
.stub(fs, 'readFile')
|
|
.rejects(new Error('Synthetic read error'));
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.calledOnceWithExactly(logServerErrorStub, {
|
|
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
|
|
});
|
|
|
|
readFileStub.restore();
|
|
});
|
|
|
|
it('returns default state and LOGS telemetry if state file is empty', async () => {
|
|
const filePath = path.join(tmpDir, 'telemetry_state.json');
|
|
await fs.writeFile(filePath, '', 'utf-8');
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.calledOnceWithExactly(logServerErrorStub, {
|
|
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
|
|
});
|
|
});
|
|
|
|
it('returns default state if lastActive is invalid date string', async () => {
|
|
const filePath = path.join(tmpDir, 'telemetry_state.json');
|
|
await fs.writeFile(
|
|
filePath,
|
|
JSON.stringify({lastActive: 'invalid-date'}),
|
|
'utf-8',
|
|
);
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.notCalled(logServerErrorStub);
|
|
});
|
|
|
|
it('returns default state if lastToolCall is invalid date string', async () => {
|
|
const filePath = path.join(tmpDir, 'telemetry_state.json');
|
|
await fs.writeFile(
|
|
filePath,
|
|
JSON.stringify({
|
|
lastActive: '2023-01-01T00:00:00.000Z',
|
|
lastToolCall: 'invalid-date',
|
|
}),
|
|
'utf-8',
|
|
);
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
|
|
assert.deepStrictEqual(state, {});
|
|
sinon.assert.notCalled(logServerErrorStub);
|
|
});
|
|
|
|
it('returns stored state if file exists', async () => {
|
|
const expectedState = {
|
|
lastActive: '2023-01-01T00:00:00.000Z',
|
|
lastToolCall: '2023-01-01T12:00:00.000Z',
|
|
};
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'telemetry_state.json'),
|
|
JSON.stringify(expectedState),
|
|
);
|
|
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
const state = await filePersistence.loadState();
|
|
assert.deepStrictEqual(state, expectedState);
|
|
});
|
|
});
|
|
|
|
describe('saveState', () => {
|
|
it('saves state to file', async () => {
|
|
const state = {
|
|
lastActive: '2023-01-01T00:00:00.000Z',
|
|
lastToolCall: '2023-01-01T12:00:00.000Z',
|
|
};
|
|
const filePersistence = new persistence.FilePersistence(tmpDir);
|
|
await filePersistence.saveState(state);
|
|
|
|
const content = await fs.readFile(
|
|
path.join(tmpDir, 'telemetry_state.json'),
|
|
'utf-8',
|
|
);
|
|
assert.deepStrictEqual(JSON.parse(content), state);
|
|
sinon.assert.notCalled(logServerErrorStub);
|
|
});
|
|
|
|
it('logs telemetry when failing to save to file', async () => {
|
|
// Force error by replacing directory with a file, causing mkdir to fail.
|
|
const dirPath = path.join(tmpDir, 'blocked_dir');
|
|
await fs.writeFile(dirPath, 'i-am-a-file');
|
|
const filePersistence = new persistence.FilePersistence(dirPath);
|
|
|
|
const state = {
|
|
lastActive: '2023-01-01T00:00:00.000Z',
|
|
};
|
|
await filePersistence.saveState(state);
|
|
|
|
sinon.assert.calledOnceWithExactly(logServerErrorStub, {
|
|
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED,
|
|
});
|
|
});
|
|
});
|
|
});
|