Skip to content

Commit 5fa00e8

Browse files
committed
Refactor SharedStateService with dual format support and comprehensive tests
- Added dual format support for legacy and new raw DOM data - Created ElementProcessor service for server-side DOM processing - Added comprehensive test suite with factory pattern - Added DOM_ELEMENT_POINTED message type support - Maintains full backward compatibility Server ready for browser extension updates.
1 parent 3f2f66f commit 5fa00e8

File tree

13 files changed

+969
-125
lines changed

13 files changed

+969
-125
lines changed

.claude/plan.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Refactoring Plan: Raw/Processed Data Architecture
2+
3+
## Phase 1: Type System Updates ✅ COMPLETED
4+
5+
1. Update PointerMessageType enum
6+
- [x] Rename ELEMENT_SELECTED to LEGACY_ELEMENT_SELECTED (keep value 'element-selected')
7+
- [x] Add new DOM_ELEMENT_POINTED = 'dom-element-pointed'
8+
- [x] Remove unused: ELEMENT_CLEARED, CONNECTION_TEST, SERVER_STATUS
9+
2. Create new data types
10+
- [x] RawPointedDOMElement: Minimal browser data (outerHTML, boundingClientRect, url, timestamp)
11+
- [x] ProcessedPointedDOMElement: Server-processed data with extracted metadata
12+
- [x] StoredPointedData: Container with integer stateVersion, raw, processed, and metadata
13+
14+
## Phase 2: Browser Extension Updates
15+
16+
1. Keep existing collection (for backward compatibility)
17+
- [x] Continue sending LEGACY_ELEMENT_SELECTED with full TargetedElement (automatic)
18+
2. Prepare new collection (deploy after server)
19+
- [ ] Create minimal RawPointedDOMElement collector
20+
- [ ] Send via DOM_ELEMENT_POINTED message type
21+
- [ ] Include: outerHTML, boundingClientRect, url, timestamp
22+
- [ ] Optional: computedStyles, reactFiber (based on config)
23+
24+
## Phase 3: Server-Side Processing ✅ COMPLETED
25+
26+
1. Create ElementProcessor service
27+
- [x] Parse HTML using jsdom
28+
- [x] Extract: tagName, id, classes, attributes, innerText
29+
- [x] Generate selectors
30+
- [x] Process optional data (CSS, React info)
31+
- [x] Include fail-safe defaults and warning collection
32+
2. Update message handlers
33+
- [x] Support both LEGACY_ELEMENT_SELECTED and DOM_ELEMENT_POINTED
34+
- [x] Legacy: Store as stateVersion 1, use data as both raw and processed
35+
- [x] New: Store as stateVersion 2, process server-side
36+
- [x] Clean switch-based architecture with separate builder functions
37+
3. Update SharedStateService
38+
- [x] Store StoredPointedData format instead of raw element
39+
- [x] Include metadata (timestamps, message type)
40+
- [x] Maintain backward compatibility for reading old format
41+
42+
## Phase 4: MCP Service Updates ✅ COMPLETED
43+
44+
1. Update get-pointed-element tool
45+
- [x] Return processedPointedDOMElement to agents
46+
- [x] Include stateVersion in response metadata
47+
- [x] Handle legacy format transparently
48+
49+
## Phase 5: Utilities & Safety ✅ COMPLETED
50+
51+
1. Implement safe extraction utilities
52+
- [x] Create lightweight safeGet function (using lodash.get)
53+
- [x] Build DOM extractor with error handling
54+
- [x] Collect warnings without throwing errors
55+
2. Add lodash-es for tree-shakeable utilities
56+
- [x] Use import { get } from 'lodash-es' for safe property access
57+
- [x] Only import needed functions
58+
59+
## Deployment Strategy
60+
61+
1. ✅ Day 1: Deploy server with dual format support
62+
2. Day 2-3: Update Chrome extension to use new format
63+
3. Week 2: Monitor and fix any edge cases
64+
4. Month 2: Consider deprecating legacy format
65+
66+
## Key Principles
67+
68+
- [x] Fail-safe: Always return some data, even if incomplete
69+
- [x] Backward compatible: No breaking changes during transition
70+
- [x] Truly raw: Send actual DOM serialization (outerHTML)
71+
- [x] Server processing: Process and extract metadata server-side
72+
- [x] Integer versioning: Simple stateVersion (1, 2, 3...)
73+
- [x] Warning system: Track issues without failing
74+
75+
## Benefits Achieved
76+
77+
- [x] Smaller payload from browser (1-5KB vs 10-50KB when Phase 2 is deployed)
78+
- [x] Centralized processing logic
79+
- [x] Future-proof versioning system
80+
- [x] Better separation of concerns
81+
- [x] Maintains service during migration
82+
- [x] Clean architecture with separated builders, processors, and storage
83+
84+
## Implementation Status
85+
86+
**✅ PHASE 1-5 COMPLETE** - Server is ready for dual format support!
87+
88+
- All tests pass ✅
89+
- Build successful ✅
90+
- Backward compatibility verified ✅
91+
- New processed data architecture implemented ✅
92+
93+
**Next Step:** Update Chrome extension to send new `RawPointedDOMElement` format

packages/server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
"dependencies": {
2626
"@modelcontextprotocol/sdk": "^0.5.0",
2727
"commander": "^11.1.0",
28+
"jsdom": "^27.0.0",
2829
"ws": "^8.16.0"
2930
},
3031
"devDependencies": {
3132
"@mcp-pointer/shared": "workspace:*",
3233
"@types/jest": "^30.0.0",
34+
"@types/jsdom": "^21.1.7",
3335
"@types/ws": "^8.5.10",
3436
"esbuild": "catalog:",
3537
"jest": "^30.1.3",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { TargetedElement, RawPointedDOMElement, PointerMessageType } from '@mcp-pointer/shared/types';
2+
import {
3+
SharedState, StateDataV1, StateDataV2, ProcessedPointedDOMElement,
4+
} from '../../types';
5+
6+
export const createProcessedElement = (
7+
overrides: Partial<ProcessedPointedDOMElement> = {},
8+
): ProcessedPointedDOMElement => ({
9+
tagName: 'div',
10+
classes: [],
11+
attributes: {},
12+
innerText: 'test content',
13+
selector: 'div',
14+
position: {
15+
x: 10, y: 20, width: 100, height: 50,
16+
},
17+
url: 'https://example.com',
18+
timestamp: '2023-01-01T00:00:00.000Z',
19+
...overrides,
20+
});
21+
22+
export const createRawElement = (
23+
overrides: Partial<RawPointedDOMElement> = {},
24+
): RawPointedDOMElement => ({
25+
outerHTML: '<div>test content</div>',
26+
url: 'https://example.com',
27+
timestamp: 1672531200000,
28+
boundingClientRect: {
29+
x: 10,
30+
y: 20,
31+
width: 100,
32+
height: 50,
33+
top: 20,
34+
right: 110,
35+
bottom: 70,
36+
left: 10,
37+
toJSON() { return this; },
38+
},
39+
...overrides,
40+
});
41+
42+
export const createLegacyElement = (
43+
overrides: Partial<TargetedElement> = {},
44+
): TargetedElement => ({
45+
selector: 'div',
46+
tagName: 'div',
47+
classes: [],
48+
innerText: 'test content',
49+
attributes: {},
50+
position: {
51+
x: 10, y: 20, width: 100, height: 50,
52+
},
53+
cssProperties: {
54+
display: 'block',
55+
position: 'relative',
56+
fontSize: '16px',
57+
color: '#000000',
58+
backgroundColor: '#ffffff',
59+
},
60+
timestamp: 1672531200000,
61+
url: 'https://example.com',
62+
...overrides,
63+
});
64+
65+
export const createStateV2 = (
66+
rawOverrides: Partial<RawPointedDOMElement> = {},
67+
processedOverrides: Partial<ProcessedPointedDOMElement> = {},
68+
): SharedState => ({
69+
stateVersion: 2,
70+
data: {
71+
rawPointedDOMElement: createRawElement(rawOverrides),
72+
processedPointedDOMElement: createProcessedElement(processedOverrides),
73+
metadata: {
74+
receivedAt: '2023-01-01T00:00:00.000Z',
75+
messageType: PointerMessageType.DOM_ELEMENT_POINTED,
76+
},
77+
} as StateDataV2,
78+
});
79+
80+
export const createStateV1 = (
81+
legacyOverrides: Partial<TargetedElement> = {},
82+
processedOverrides: Partial<ProcessedPointedDOMElement> = {},
83+
): SharedState => ({
84+
stateVersion: 1,
85+
data: {
86+
rawPointedDOMElement: createLegacyElement(legacyOverrides),
87+
processedPointedDOMElement: createProcessedElement(processedOverrides),
88+
metadata: {
89+
receivedAt: '2023-01-01T00:00:00.000Z',
90+
messageType: PointerMessageType.LEGACY_ELEMENT_SELECTED,
91+
},
92+
} as StateDataV1,
93+
});
Lines changed: 70 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,97 @@
11
import fs from 'fs/promises';
2+
import path from 'path';
3+
import os from 'os';
24
import SharedStateService from '../../services/shared-state-service';
3-
import {
4-
setupTestDir, cleanupTestFiles, createMockElement, TEST_SHARED_STATE_PATH,
5-
} from '../test-helpers';
5+
import { createStateV1, createStateV2, createLegacyElement } from '../factories/shared-state-factory';
6+
7+
jest.mock('../../logger', () => ({
8+
debug: jest.fn(),
9+
error: jest.fn(),
10+
}));
611

712
describe('SharedStateService', () => {
813
let service: SharedStateService;
14+
let testPath: string;
915

10-
beforeAll(async () => {
11-
await setupTestDir();
16+
beforeEach(async () => {
17+
testPath = path.join(os.tmpdir(), `test-${Date.now()}.json`);
18+
(SharedStateService as any).SHARED_STATE_PATH = testPath;
19+
service = new SharedStateService();
20+
});
1221

13-
// Monkey-patch the constant for testing
14-
const SharedStateServiceModule = await import('../../services/shared-state-service');
15-
const SharedStateServiceClass = SharedStateServiceModule.default;
22+
afterEach(async () => {
23+
try {
24+
await fs.unlink(testPath);
25+
} catch {
26+
// ignore
27+
}
28+
});
1629

17-
// Override the static constant
18-
(SharedStateServiceClass as any).SHARED_STATE_PATH = TEST_SHARED_STATE_PATH;
30+
describe('saveState', () => {
31+
it('writes state to file', async () => {
32+
const state = createStateV2();
1933

20-
service = new SharedStateServiceClass();
21-
});
34+
await service.saveState(state);
2235

23-
afterAll(async () => {
24-
await cleanupTestFiles();
25-
});
36+
const content = await fs.readFile(testPath, 'utf8');
37+
const parsed = JSON.parse(content);
38+
expect(parsed.stateVersion).toBe(state.stateVersion);
39+
expect(parsed.data.processedPointedDOMElement).toEqual(state.data.processedPointedDOMElement);
40+
});
2641

27-
it('should save and load current element', async () => {
28-
const mockElement = createMockElement();
29-
30-
await service.saveCurrentElement(mockElement);
31-
const loadedElement = await service.getCurrentElement();
32-
33-
expect(loadedElement).toBeTruthy();
34-
expect(loadedElement!.selector).toBe(mockElement.selector);
35-
expect(loadedElement!.tagName).toBe(mockElement.tagName);
36-
expect(loadedElement!.id).toBe(mockElement.id);
37-
expect(loadedElement!.classes).toEqual(mockElement.classes);
38-
expect(loadedElement!.innerText).toBe(mockElement.innerText);
39-
expect(loadedElement!.attributes).toEqual(mockElement.attributes);
40-
expect(loadedElement!.position).toEqual(mockElement.position);
41-
expect(loadedElement!.cssProperties).toEqual(mockElement.cssProperties);
42-
expect(loadedElement!.url).toBe(mockElement.url);
43-
expect(loadedElement!.tabId).toBe(mockElement.tabId);
44-
});
42+
it('overwrites corrupted file', async () => {
43+
await fs.writeFile(testPath, 'invalid json');
44+
const state = createStateV1();
4545

46-
it('should handle null element', async () => {
47-
await service.saveCurrentElement(null);
48-
const loadedElement = await service.getCurrentElement();
46+
await service.saveState(state);
4947

50-
expect(loadedElement).toBeNull();
48+
const content = await fs.readFile(testPath, 'utf8');
49+
const parsed = JSON.parse(content);
50+
expect(parsed.stateVersion).toBe(state.stateVersion);
51+
expect(parsed.data.processedPointedDOMElement).toEqual(state.data.processedPointedDOMElement);
52+
});
5153
});
5254

53-
it('should return null for missing file', async () => {
54-
// Delete the file if it exists
55-
try {
56-
await fs.unlink(TEST_SHARED_STATE_PATH);
57-
} catch {
58-
// File doesn't exist, which is fine
59-
}
55+
describe('getPointedElement', () => {
56+
it('returns processed element from v2 state', async () => {
57+
const state = createStateV2();
58+
await fs.writeFile(testPath, JSON.stringify(state));
6059

61-
const loadedElement = await service.getCurrentElement();
62-
expect(loadedElement).toBeNull();
63-
});
60+
const result = await service.getPointedElement();
6461

65-
it('should handle corrupted file gracefully', async () => {
66-
// Write invalid JSON to the file
67-
await fs.writeFile(TEST_SHARED_STATE_PATH, 'invalid json content');
62+
expect(result).toEqual(state.data.processedPointedDOMElement);
63+
});
6864

69-
const loadedElement = await service.getCurrentElement();
70-
expect(loadedElement).toBeNull();
71-
});
65+
it('returns processed element from v1 state', async () => {
66+
const state = createStateV1();
67+
await fs.writeFile(testPath, JSON.stringify(state));
7268

73-
it('should save element over corrupted file', async () => {
74-
// Write invalid JSON to the file
75-
await fs.writeFile(TEST_SHARED_STATE_PATH, 'invalid json content');
69+
const result = await service.getPointedElement();
7670

77-
const mockElement = createMockElement();
78-
await service.saveCurrentElement(mockElement);
71+
expect(result).toEqual(state.data.processedPointedDOMElement);
72+
});
7973

80-
const loadedElement = await service.getCurrentElement();
81-
expect(loadedElement).toBeTruthy();
82-
expect(loadedElement!.selector).toBe(mockElement.selector);
83-
});
74+
it('returns legacy element as-is', async () => {
75+
const legacyElement = createLegacyElement();
76+
await fs.writeFile(testPath, JSON.stringify(legacyElement));
77+
78+
const result = await service.getPointedElement();
79+
80+
expect(result).toEqual(legacyElement);
81+
});
82+
83+
it('returns null for invalid json', async () => {
84+
await fs.writeFile(testPath, 'invalid json');
8485

85-
it('should overwrite previous element', async () => {
86-
const firstElement = createMockElement();
87-
firstElement.selector = 'div.first-element';
86+
const result = await service.getPointedElement();
8887

89-
const secondElement = createMockElement();
90-
secondElement.selector = 'div.second-element';
88+
expect(result).toBeNull();
89+
});
9190

92-
await service.saveCurrentElement(firstElement);
93-
await service.saveCurrentElement(secondElement);
91+
it('returns null when file does not exist', async () => {
92+
const result = await service.getPointedElement();
9493

95-
const loadedElement = await service.getCurrentElement();
96-
expect(loadedElement).toBeTruthy();
97-
expect(loadedElement!.selector).toBe('div.second-element');
94+
expect(result).toBeNull();
95+
});
9896
});
9997
});

0 commit comments

Comments
 (0)