Skip to content

Commit fc911b1

Browse files
committed
feat(server): cover element detail normalization and shaping
1 parent ace6472 commit fc911b1

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
normalizeDetailParameters,
3+
normalizeCssLevel,
4+
normalizeTextDetail,
5+
shapeElementForDetail,
6+
} from '../../utils/element-detail';
7+
import { createMockElement } from '../test-helpers';
8+
9+
describe('element-detail utilities', () => {
10+
describe('normalizeTextDetail', () => {
11+
it('returns defaults for invalid values', () => {
12+
expect(normalizeTextDetail(undefined)).toBe('full');
13+
expect(normalizeTextDetail('VISIBLE')).toBe('visible');
14+
expect(normalizeTextDetail('invalid', 'visible')).toBe('visible');
15+
});
16+
});
17+
18+
describe('normalizeCssLevel', () => {
19+
it('coerces numeric strings and falls back to default', () => {
20+
expect(normalizeCssLevel('2')).toBe(2);
21+
expect(normalizeCssLevel('not-a-number', 3)).toBe(3);
22+
expect(normalizeCssLevel(undefined)).toBe(1);
23+
});
24+
});
25+
26+
describe('normalizeDetailParameters', () => {
27+
it('applies defaults when params are missing', () => {
28+
expect(normalizeDetailParameters(undefined)).toEqual({
29+
textDetail: 'full',
30+
cssLevel: 1,
31+
});
32+
});
33+
34+
it('normalizes provided params', () => {
35+
expect(normalizeDetailParameters({ textDetail: 'visible', cssLevel: '0' })).toEqual({
36+
textDetail: 'visible',
37+
cssLevel: 0,
38+
});
39+
});
40+
});
41+
42+
describe('shapeElementForDetail', () => {
43+
it('omits text and css when levels request none', () => {
44+
const element = createMockElement();
45+
const shaped = shapeElementForDetail(element, 'none', 0);
46+
47+
expect(shaped.innerText).toBeUndefined();
48+
expect(shaped.textContent).toBeUndefined();
49+
expect(shaped.cssProperties).toBeUndefined();
50+
expect(shaped.cssLevel).toBe(0);
51+
});
52+
53+
it('returns visible text and level 1 css subset', () => {
54+
const element = createMockElement();
55+
element.textVariants!.visible = 'Visible text only';
56+
element.textVariants!.full = 'Visible text only with hidden';
57+
const shaped = shapeElementForDetail(element, 'visible', 1);
58+
59+
expect(shaped.innerText).toBe('Visible text only');
60+
expect(shaped.textContent).toBeUndefined();
61+
expect(shaped.cssProperties).toBeDefined();
62+
expect(Object.keys(shaped.cssProperties!)).toContain('display');
63+
expect(Object.keys(shaped.cssProperties!)).not.toContain('marginTop');
64+
});
65+
66+
it('returns full css when level 3 requested', () => {
67+
const element = createMockElement();
68+
element.cssComputed = {
69+
...element.cssProperties!,
70+
marginTop: '5px',
71+
};
72+
73+
const shaped = shapeElementForDetail(element, 'full', 3);
74+
expect(shaped.cssProperties).toEqual({
75+
...element.cssComputed,
76+
});
77+
expect(shaped.textContent).toBe(element.textVariants!.full);
78+
});
79+
});
80+
});
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
CSSDetailLevel,
3+
CSSProperties,
4+
DEFAULT_CSS_LEVEL,
5+
DEFAULT_TEXT_DETAIL,
6+
TargetedElement,
7+
TextDetailLevel,
8+
TextSnapshots,
9+
} from '@mcp-pointer/shared/types';
10+
import {
11+
CSS_LEVEL_FIELD_MAP,
12+
isValidCSSLevel,
13+
isValidTextDetail,
14+
} from '@mcp-pointer/shared/detail';
15+
16+
export interface DetailParameters {
17+
textDetail?: unknown;
18+
cssLevel?: unknown;
19+
}
20+
21+
export interface NormalizedDetailParameters {
22+
textDetail: TextDetailLevel;
23+
cssLevel: CSSDetailLevel;
24+
}
25+
26+
function toNumber(value: unknown): number | null {
27+
if (typeof value === 'number' && Number.isFinite(value)) {
28+
return value;
29+
}
30+
31+
if (typeof value === 'string' && value.trim().length > 0) {
32+
const parsed = Number(value);
33+
if (!Number.isNaN(parsed)) {
34+
return parsed;
35+
}
36+
}
37+
38+
return null;
39+
}
40+
41+
export function normalizeTextDetail(
42+
detail: unknown,
43+
fallback: TextDetailLevel = DEFAULT_TEXT_DETAIL,
44+
): TextDetailLevel {
45+
if (isValidTextDetail(detail)) {
46+
return detail;
47+
}
48+
49+
if (typeof detail === 'string') {
50+
const lowered = detail.toLowerCase();
51+
if (isValidTextDetail(lowered)) {
52+
return lowered as TextDetailLevel;
53+
}
54+
}
55+
56+
return fallback;
57+
}
58+
59+
export function normalizeCssLevel(
60+
level: unknown,
61+
fallback: CSSDetailLevel = DEFAULT_CSS_LEVEL,
62+
): CSSDetailLevel {
63+
if (isValidCSSLevel(level)) {
64+
return level;
65+
}
66+
67+
const parsed = toNumber(level);
68+
if (parsed !== null && isValidCSSLevel(parsed)) {
69+
return parsed;
70+
}
71+
72+
return fallback;
73+
}
74+
75+
export function normalizeDetailParameters(
76+
params: DetailParameters | undefined,
77+
defaults?: Partial<NormalizedDetailParameters>,
78+
): NormalizedDetailParameters {
79+
return {
80+
textDetail: normalizeTextDetail(
81+
params?.textDetail,
82+
defaults?.textDetail ?? DEFAULT_TEXT_DETAIL,
83+
),
84+
cssLevel: normalizeCssLevel(
85+
params?.cssLevel,
86+
defaults?.cssLevel ?? DEFAULT_CSS_LEVEL,
87+
),
88+
};
89+
}
90+
91+
function resolveTextVariants(element: TargetedElement): TextSnapshots {
92+
const visible = element.textVariants?.visible ?? element.innerText ?? '';
93+
const full = element.textVariants?.full ?? element.textContent ?? visible;
94+
95+
return {
96+
visible,
97+
full,
98+
};
99+
}
100+
101+
function resolveTextContent(
102+
variants: TextSnapshots,
103+
detail: TextDetailLevel,
104+
): string | undefined {
105+
if (detail === 'none') {
106+
return undefined;
107+
}
108+
109+
if (detail === 'visible') {
110+
return variants.visible;
111+
}
112+
113+
return variants.full || variants.visible;
114+
}
115+
116+
function buildCssProperties(
117+
element: TargetedElement,
118+
cssLevel: CSSDetailLevel,
119+
): CSSProperties | undefined {
120+
if (cssLevel === 0) {
121+
return undefined;
122+
}
123+
124+
if (cssLevel === 3) {
125+
if (element.cssComputed) {
126+
return { ...element.cssComputed };
127+
}
128+
129+
if (element.cssProperties) {
130+
return { ...element.cssProperties };
131+
}
132+
133+
return undefined;
134+
}
135+
136+
const fields = CSS_LEVEL_FIELD_MAP[cssLevel];
137+
const cssProperties: CSSProperties = {};
138+
const source = element.cssComputed ?? element.cssProperties ?? {};
139+
140+
fields.forEach((property) => {
141+
const value = source[property];
142+
if (value !== undefined) {
143+
cssProperties[property] = value;
144+
}
145+
});
146+
147+
if (Object.keys(cssProperties).length > 0) {
148+
return cssProperties;
149+
}
150+
151+
if (element.cssProperties) {
152+
return { ...element.cssProperties };
153+
}
154+
155+
return undefined;
156+
}
157+
158+
export function shapeElementForDetail(
159+
element: TargetedElement,
160+
detail: TextDetailLevel,
161+
cssLevel: CSSDetailLevel,
162+
): TargetedElement {
163+
const variants = resolveTextVariants(element);
164+
const resolvedText = resolveTextContent(variants, detail);
165+
const textContent = detail === 'full' ? variants.full : undefined;
166+
const cssProperties = buildCssProperties(element, cssLevel);
167+
168+
const shaped: TargetedElement = {
169+
selector: element.selector,
170+
tagName: element.tagName,
171+
id: element.id,
172+
classes: [...element.classes],
173+
attributes: { ...element.attributes },
174+
position: { ...element.position },
175+
cssLevel,
176+
componentInfo: element.componentInfo ? { ...element.componentInfo } : undefined,
177+
timestamp: element.timestamp,
178+
url: element.url,
179+
tabId: element.tabId,
180+
textDetail: detail,
181+
};
182+
183+
if (resolvedText !== undefined) {
184+
shaped.innerText = resolvedText;
185+
}
186+
187+
if (textContent !== undefined) {
188+
shaped.textContent = textContent;
189+
}
190+
191+
if (cssProperties) {
192+
shaped.cssProperties = cssProperties;
193+
}
194+
195+
return shaped;
196+
}

0 commit comments

Comments
 (0)