Skip to content

Commit d74cd48

Browse files
committed
feat(shared): switch detail levels to enums
1 parent db24adb commit d74cd48

File tree

5 files changed

+91
-58
lines changed

5 files changed

+91
-58
lines changed

packages/server/src/__tests__/test-helpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs/promises';
22
import path from 'path';
33
import os from 'os';
4-
import { TargetedElement } from '@mcp-pointer/shared/types';
4+
import { TargetedElement, TextDetailLevel, CSSDetailLevel } from '@mcp-pointer/shared/types';
55

66
// Test constants - use a temp directory that works in Jest
77
export const TEST_MCP_POINTER_PORT = 7008;
@@ -33,7 +33,7 @@ export function createMockElement(): TargetedElement {
3333
classes: ['test-class'],
3434
innerText: text,
3535
textContent: text,
36-
textDetail: 'full',
36+
textDetail: TextDetailLevel.FULL,
3737
textVariants: {
3838
visible: text,
3939
full: text,
@@ -42,7 +42,7 @@ export function createMockElement(): TargetedElement {
4242
position: {
4343
x: 100, y: 200, width: 300, height: 50,
4444
},
45-
cssLevel: 1,
45+
cssLevel: CSSDetailLevel.BASIC,
4646
cssProperties: {
4747
display: 'block',
4848
position: 'relative',

packages/server/src/__tests__/utils/element-detail.test.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { CSSDetailLevel, TextDetailLevel } from '@mcp-pointer/shared/types';
12
import {
23
normalizeDetailParameters,
34
normalizeCssLevel,
45
normalizeTextDetail,
5-
shapeElementForDetail,
6+
serializeElement,
67
} from '../../utils/element-detail';
78
import { ProcessedPointedDOMElement } from '../../types';
89

@@ -18,15 +19,6 @@ function createMockProcessedElement(): ProcessedPointedDOMElement {
1819
position: {
1920
x: 100, y: 200, width: 300, height: 50,
2021
},
21-
cssProperties: {
22-
display: 'block',
23-
position: 'relative',
24-
fontSize: '16px',
25-
color: 'rgb(0, 0, 0)',
26-
backgroundColor: 'rgb(255, 255, 255)',
27-
marginTop: '10px',
28-
paddingLeft: '5px',
29-
},
3022
cssComputed: {
3123
display: 'block',
3224
position: 'relative',
@@ -44,40 +36,44 @@ function createMockProcessedElement(): ProcessedPointedDOMElement {
4436
describe('element-detail utilities', () => {
4537
describe('normalizeTextDetail', () => {
4638
it('returns defaults for invalid values', () => {
47-
expect(normalizeTextDetail(undefined)).toBe('full');
48-
expect(normalizeTextDetail('VISIBLE')).toBe('visible');
49-
expect(normalizeTextDetail('invalid', 'visible')).toBe('visible');
39+
expect(normalizeTextDetail(undefined)).toBe(TextDetailLevel.FULL);
40+
expect(normalizeTextDetail('VISIBLE')).toBe(TextDetailLevel.VISIBLE);
41+
expect(normalizeTextDetail('invalid', TextDetailLevel.VISIBLE)).toBe(TextDetailLevel.VISIBLE);
5042
});
5143
});
5244

5345
describe('normalizeCssLevel', () => {
5446
it('coerces numeric strings and falls back to default', () => {
55-
expect(normalizeCssLevel('2')).toBe(2);
56-
expect(normalizeCssLevel('not-a-number', 3)).toBe(3);
57-
expect(normalizeCssLevel(undefined)).toBe(1);
47+
expect(normalizeCssLevel('2')).toBe(CSSDetailLevel.BOX_MODEL);
48+
expect(normalizeCssLevel('not-a-number', CSSDetailLevel.FULL)).toBe(CSSDetailLevel.FULL);
49+
expect(normalizeCssLevel(undefined)).toBe(CSSDetailLevel.BASIC);
5850
});
5951
});
6052

6153
describe('normalizeDetailParameters', () => {
6254
it('applies defaults when params are missing', () => {
6355
expect(normalizeDetailParameters(undefined)).toEqual({
64-
textDetail: 'full',
65-
cssLevel: 1,
56+
textDetail: TextDetailLevel.FULL,
57+
cssLevel: CSSDetailLevel.BASIC,
6658
});
6759
});
6860

6961
it('normalizes provided params', () => {
7062
expect(normalizeDetailParameters({ textDetail: 'visible', cssLevel: '0' })).toEqual({
71-
textDetail: 'visible',
72-
cssLevel: 0,
63+
textDetail: TextDetailLevel.VISIBLE,
64+
cssLevel: CSSDetailLevel.NONE,
7365
});
7466
});
7567
});
7668

77-
describe('shapeElementForDetail', () => {
69+
describe('serializeElement', () => {
7870
it('omits text and css when levels request none', () => {
7971
const element = createMockProcessedElement();
80-
const shaped = shapeElementForDetail(element, 'none', 0);
72+
const shaped = serializeElement(
73+
element,
74+
TextDetailLevel.NONE,
75+
CSSDetailLevel.NONE,
76+
);
8177

8278
expect(shaped.innerText).toBe('');
8379
expect(shaped.textContent).toBeUndefined();
@@ -88,7 +84,11 @@ describe('element-detail utilities', () => {
8884
const element = createMockProcessedElement();
8985
element.innerText = 'Visible text only';
9086
element.textContent = 'Visible text only with hidden';
91-
const shaped = shapeElementForDetail(element, 'visible', 1);
87+
const shaped = serializeElement(
88+
element,
89+
TextDetailLevel.VISIBLE,
90+
CSSDetailLevel.BASIC,
91+
);
9292

9393
expect(shaped.innerText).toBe('Visible text only');
9494
expect(shaped.textContent).toBeUndefined();
@@ -99,7 +99,11 @@ describe('element-detail utilities', () => {
9999

100100
it('returns full css when level 3 requested', () => {
101101
const element = createMockProcessedElement();
102-
const shaped = shapeElementForDetail(element, 'full', 3);
102+
const shaped = serializeElement(
103+
element,
104+
TextDetailLevel.FULL,
105+
CSSDetailLevel.FULL,
106+
);
103107

104108
expect(shaped.cssProperties).toEqual(element.cssComputed);
105109
expect(shaped.textContent).toBe(element.textContent);

packages/server/src/utils/element-detail.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
isValidCSSLevel,
1111
isValidTextDetail,
1212
} from '@mcp-pointer/shared/detail';
13-
import { ProcessedPointedDOMElement } from '../types';
13+
import { ProcessedPointedDOMElement, SerializedDOMElement } from '../types';
1414

1515
export interface DetailParameters {
1616
textDetail?: unknown;
@@ -37,6 +37,17 @@ function toNumber(value: unknown): number | null {
3737
return null;
3838
}
3939

40+
const TEXT_DETAIL_ALIAS_MAP: Record<string, TextDetailLevel> = {
41+
full: TextDetailLevel.FULL,
42+
visible: TextDetailLevel.VISIBLE,
43+
none: TextDetailLevel.NONE,
44+
};
45+
46+
function fromTextDetailAlias(value: string): TextDetailLevel | null {
47+
const normalized = value.trim().toLowerCase();
48+
return TEXT_DETAIL_ALIAS_MAP[normalized] ?? null;
49+
}
50+
4051
export function normalizeTextDetail(
4152
detail: unknown,
4253
fallback: TextDetailLevel = DEFAULT_TEXT_DETAIL,
@@ -46,9 +57,14 @@ export function normalizeTextDetail(
4657
}
4758

4859
if (typeof detail === 'string') {
49-
const lowered = detail.toLowerCase();
50-
if (isValidTextDetail(lowered)) {
51-
return lowered as TextDetailLevel;
60+
const alias = fromTextDetailAlias(detail);
61+
if (alias !== null) {
62+
return alias;
63+
}
64+
65+
const parsed = toNumber(detail);
66+
if (parsed !== null && isValidTextDetail(parsed)) {
67+
return parsed;
5268
}
5369
}
5470

@@ -91,41 +107,37 @@ function resolveTextContent(
91107
element: ProcessedPointedDOMElement,
92108
detail: TextDetailLevel,
93109
): string | undefined {
94-
if (detail === 'none') {
110+
if (detail === TextDetailLevel.NONE) {
95111
return undefined;
96112
}
97113

98-
if (detail === 'visible') {
114+
if (detail === TextDetailLevel.VISIBLE) {
99115
return element.innerText;
100116
}
101117

102-
// 'full' - return textContent if available, otherwise innerText
118+
// Full detail returns textContent if available, otherwise falls back to innerText
103119
return element.textContent ?? element.innerText;
104120
}
105121

106122
function buildCssProperties(
107123
element: ProcessedPointedDOMElement,
108124
cssLevel: CSSDetailLevel,
109125
): CSSProperties | undefined {
110-
if (cssLevel === 0) {
126+
if (cssLevel === CSSDetailLevel.NONE) {
111127
return undefined;
112128
}
113129

114-
if (cssLevel === 3) {
130+
if (cssLevel === CSSDetailLevel.FULL) {
115131
if (element.cssComputed) {
116132
return { ...element.cssComputed };
117133
}
118134

119-
if (element.cssProperties) {
120-
return { ...element.cssProperties };
121-
}
122-
123135
return undefined;
124136
}
125137

126138
const fields = CSS_LEVEL_FIELD_MAP[cssLevel];
127139
const cssProperties: CSSProperties = {};
128-
const source = element.cssComputed ?? element.cssProperties ?? {};
140+
const source = element.cssComputed ?? {};
129141

130142
fields.forEach((property) => {
131143
const value = source[property];
@@ -138,23 +150,23 @@ function buildCssProperties(
138150
return cssProperties;
139151
}
140152

141-
if (element.cssProperties) {
142-
return { ...element.cssProperties };
153+
if (element.cssComputed) {
154+
return { ...element.cssComputed };
143155
}
144156

145157
return undefined;
146158
}
147159

148-
export function shapeElementForDetail(
160+
export function serializeElement(
149161
element: ProcessedPointedDOMElement,
150162
detail: TextDetailLevel,
151163
cssLevel: CSSDetailLevel,
152-
): ProcessedPointedDOMElement {
164+
): SerializedDOMElement {
153165
const resolvedText = resolveTextContent(element, detail);
154-
const textContent = detail === 'full' ? element.textContent : undefined;
166+
const textContent = detail === TextDetailLevel.FULL ? element.textContent : undefined;
155167
const cssProperties = buildCssProperties(element, cssLevel);
156168

157-
const shaped: ProcessedPointedDOMElement = {
169+
const shaped: SerializedDOMElement = {
158170
selector: element.selector,
159171
tagName: element.tagName,
160172
id: element.id,

packages/shared/src/detail.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { CSSDetailLevel, TextDetailLevel } from './types';
22

3-
export const TEXT_DETAIL_OPTIONS: readonly TextDetailLevel[] = ['full', 'visible', 'none'];
3+
function getEnumNumberValues<T extends Record<string, string | number>>(enumObj: T): number[] {
4+
return Object.values(enumObj).filter((value): value is number => typeof value === 'number');
5+
}
6+
7+
export const TEXT_DETAIL_OPTIONS: readonly TextDetailLevel[] = Object.freeze(
8+
getEnumNumberValues(TextDetailLevel) as TextDetailLevel[],
9+
);
410

5-
export const CSS_DETAIL_OPTIONS: readonly CSSDetailLevel[] = [0, 1, 2, 3];
11+
export const CSS_DETAIL_OPTIONS: readonly CSSDetailLevel[] = Object.freeze(
12+
getEnumNumberValues(CSSDetailLevel) as CSSDetailLevel[],
13+
);
614

715
export const CSS_LEVEL_1_FIELDS: readonly string[] = [
816
'display',
@@ -59,16 +67,16 @@ export const CSS_LEVEL_2_FIELDS: readonly string[] = Object.freeze([
5967
]);
6068

6169
export const CSS_LEVEL_FIELD_MAP: Record<
62-
Exclude<CSSDetailLevel, 0>,
70+
Exclude<CSSDetailLevel, CSSDetailLevel.NONE>,
6371
readonly string[]
6472
> = Object.freeze({
65-
1: CSS_LEVEL_1_FIELDS,
66-
2: CSS_LEVEL_2_FIELDS,
67-
3: [],
73+
[CSSDetailLevel.BASIC]: CSS_LEVEL_1_FIELDS,
74+
[CSSDetailLevel.BOX_MODEL]: CSS_LEVEL_2_FIELDS,
75+
[CSSDetailLevel.FULL]: [],
6876
});
6977

7078
export function isValidTextDetail(detail: unknown): detail is TextDetailLevel {
71-
return typeof detail === 'string' && (TEXT_DETAIL_OPTIONS as readonly string[]).includes(detail);
79+
return typeof detail === 'number' && (TEXT_DETAIL_OPTIONS as readonly number[]).includes(detail);
7280
}
7381

7482
export function isValidCSSLevel(level: unknown): level is CSSDetailLevel {

packages/shared/src/types.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
export type TextDetailLevel = 'full' | 'visible' | 'none';
1+
export enum TextDetailLevel {
2+
NONE = 0,
3+
VISIBLE = 1,
4+
FULL = 2,
5+
}
26

3-
export type CSSDetailLevel = 0 | 1 | 2 | 3;
7+
export enum CSSDetailLevel {
8+
NONE = 0,
9+
BASIC = 1,
10+
BOX_MODEL = 2,
11+
FULL = 3,
12+
}
413

5-
export const DEFAULT_TEXT_DETAIL: TextDetailLevel = 'full';
14+
export const DEFAULT_TEXT_DETAIL: TextDetailLevel = TextDetailLevel.FULL;
615

7-
export const DEFAULT_CSS_LEVEL: CSSDetailLevel = 1;
16+
export const DEFAULT_CSS_LEVEL: CSSDetailLevel = CSSDetailLevel.BASIC;
817

918
export interface TextSnapshots {
1019
visible: string;

0 commit comments

Comments
 (0)