mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
687 lines
20 KiB
TypeScript
687 lines
20 KiB
TypeScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import {
|
|
parseEChartOptions,
|
|
safeParseEChartOptions,
|
|
EChartOptionsParseError,
|
|
} from './safeEChartOptionsParser';
|
|
|
|
test('parseEChartOptions returns undefined for empty input', () => {
|
|
expect(parseEChartOptions(undefined)).toEqual({
|
|
success: true,
|
|
data: undefined,
|
|
});
|
|
expect(parseEChartOptions('')).toEqual({ success: true, data: undefined });
|
|
expect(parseEChartOptions(' ')).toEqual({ success: true, data: undefined });
|
|
});
|
|
|
|
test('parseEChartOptions parses simple object literals', () => {
|
|
const input = `{ title: { text: 'My Chart' } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
title: { text: 'My Chart' },
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions parses nested objects and arrays', () => {
|
|
const input = `{
|
|
grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
|
|
xAxis: { type: 'category' },
|
|
yAxis: [{ type: 'value' }, { type: 'log' }],
|
|
series: [
|
|
{ name: 'Series 1', type: 'line' },
|
|
{ name: 'Series 2', type: 'bar' }
|
|
]
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
|
|
xAxis: { type: 'category' },
|
|
yAxis: [{ type: 'value' }, { type: 'log' }],
|
|
series: [
|
|
{ name: 'Series 1', type: 'line' },
|
|
{ name: 'Series 2', type: 'bar' },
|
|
],
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions handles negative numbers in valid properties', () => {
|
|
const input = `{ xAxis: { nameRotate: -45, offset: -10 } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ xAxis: { nameRotate: -45, offset: -10 } });
|
|
});
|
|
|
|
test('parseEChartOptions handles boolean values in valid properties', () => {
|
|
const input = `{ animation: true, useUTC: false }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ animation: true, useUTC: false });
|
|
});
|
|
|
|
test('parseEChartOptions throws for special numeric values like -Infinity', () => {
|
|
// Special values like -Infinity are not valid JSON-serializable values
|
|
const input = `{ xAxis: { min: -Infinity, splitNumber: 5 } }`;
|
|
|
|
// -Infinity is not a valid value for 'min' in the schema
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('parseEChartOptions throws for function expressions', () => {
|
|
const input = `{ formatter: function(value) { return value; } }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions throws for arrow functions', () => {
|
|
const input = `{ formatter: (value) => value }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions throws for function calls', () => {
|
|
const input = `{ value: eval('1+1') }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions throws for dangerous identifiers', () => {
|
|
const dangerousInputs = [
|
|
`{ x: window }`,
|
|
`{ x: document }`,
|
|
`{ x: globalThis }`,
|
|
`{ x: process }`,
|
|
`{ x: require }`,
|
|
`{ x: constructor }`,
|
|
`{ x: __proto__ }`,
|
|
];
|
|
|
|
dangerousInputs.forEach(input => {
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'security_error',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions throws for computed properties', () => {
|
|
const input = `{ [dynamicKey]: 'value' }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('parseEChartOptions throws for template literals with expressions', () => {
|
|
const input = '{ text: `Hello ${name}` }';
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('parseEChartOptions allows simple template literals in valid properties', () => {
|
|
const input = '{ title: { text: `Hello World` } }';
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ title: { text: 'Hello World' } });
|
|
});
|
|
|
|
test('parseEChartOptions throws for new expressions', () => {
|
|
const input = `{ date: new Date() }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('parseEChartOptions throws for member expressions', () => {
|
|
const input = `{ value: Math.PI }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('parseEChartOptions handles trailing commas (JSON5-like)', () => {
|
|
const input = `{
|
|
title: { text: 'Chart', },
|
|
series: [
|
|
{ name: 'A', },
|
|
{ name: 'B', },
|
|
],
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
title: { text: 'Chart' },
|
|
series: [{ name: 'A' }, { name: 'B' }],
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions handles unquoted keys in nested objects', () => {
|
|
// Unknown top-level keys are filtered, but valid nested keys work
|
|
const input = `{ title: { text: 'value', show: true } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
title: { text: 'value', show: true },
|
|
});
|
|
});
|
|
|
|
test('safeParseEChartOptions throws on parse error', () => {
|
|
expect(() => safeParseEChartOptions('{ invalid')).toThrow(
|
|
EChartOptionsParseError,
|
|
);
|
|
});
|
|
|
|
test('safeParseEChartOptions throws on security error', () => {
|
|
expect(() => safeParseEChartOptions('{ fn: () => {} }')).toThrow(
|
|
EChartOptionsParseError,
|
|
);
|
|
});
|
|
|
|
test('safeParseEChartOptions returns data on success', () => {
|
|
const result = safeParseEChartOptions(`{ title: { text: 'Test' } }`);
|
|
expect(result).toEqual({ title: { text: 'Test' } });
|
|
});
|
|
|
|
test('parseEChartOptions handles complex real-world EChart options', () => {
|
|
const input = `{
|
|
title: {
|
|
text: 'Sales Overview',
|
|
subtext: 'Monthly Data',
|
|
left: 'center',
|
|
textStyle: {
|
|
color: '#333',
|
|
fontSize: 18,
|
|
fontWeight: 'bold'
|
|
}
|
|
},
|
|
legend: {
|
|
orient: 'vertical',
|
|
left: 'left',
|
|
top: 50
|
|
},
|
|
grid: {
|
|
left: '3%',
|
|
right: '4%',
|
|
bottom: '3%',
|
|
containLabel: true
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
borderColor: '#ccc',
|
|
borderWidth: 1,
|
|
textStyle: {
|
|
color: '#333'
|
|
}
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
boundaryGap: false,
|
|
axisLine: {
|
|
lineStyle: {
|
|
color: '#999'
|
|
}
|
|
}
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
splitLine: {
|
|
lineStyle: {
|
|
type: 'dashed'
|
|
}
|
|
}
|
|
},
|
|
dataZoom: [
|
|
{
|
|
type: 'slider',
|
|
start: 0,
|
|
end: 100
|
|
},
|
|
{
|
|
type: 'inside'
|
|
}
|
|
]
|
|
}`;
|
|
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.title).toBeDefined();
|
|
expect(result.data?.legend).toBeDefined();
|
|
expect(result.data?.grid).toBeDefined();
|
|
expect(result.data?.tooltip).toBeDefined();
|
|
expect(result.data?.xAxis).toBeDefined();
|
|
expect(result.data?.yAxis).toBeDefined();
|
|
expect(result.data?.dataZoom).toHaveLength(2);
|
|
});
|
|
|
|
// =============================================================================
|
|
// Schema Validation Tests
|
|
// =============================================================================
|
|
|
|
test('parseEChartOptions throws when title is a string instead of object', () => {
|
|
// title should be TitleOption (object), not string
|
|
const input = `{ title: 'text' }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
expect(
|
|
(error as EChartOptionsParseError).validationErrors.length,
|
|
).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions throws when grid is a string instead of object', () => {
|
|
const input = `{ grid: 'invalid' }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions throws when nested property has wrong type', () => {
|
|
// textStyle should be object, not string - this invalidates the entire title
|
|
const input = `{ title: { text: 'Chart', textStyle: 'invalid' } }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions keeps valid nested objects', () => {
|
|
const input = `{ title: { text: 'Chart', textStyle: { color: '#333', fontSize: 14 } } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.title).toEqual({
|
|
text: 'Chart',
|
|
textStyle: { color: '#333', fontSize: 14 },
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions throws when some properties are invalid', () => {
|
|
const input = `{
|
|
title: { text: 'Valid Title' },
|
|
legend: 'invalid',
|
|
grid: { top: 50 }
|
|
}`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
expect(
|
|
(error as EChartOptionsParseError).validationErrors.some(e =>
|
|
e.includes('legend'),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions ignores unknown top-level properties', () => {
|
|
const input = `{
|
|
title: { text: 'Chart' },
|
|
unknownProperty: 'should be filtered',
|
|
anotherUnknown: { nested: 'value' }
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.title).toEqual({ text: 'Chart' });
|
|
expect(
|
|
(result.data as Record<string, unknown>)?.unknownProperty,
|
|
).toBeUndefined();
|
|
expect(
|
|
(result.data as Record<string, unknown>)?.anotherUnknown,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test('parseEChartOptions throws when array has invalid items', () => {
|
|
// dataZoom array should contain objects, not strings
|
|
const input = `{
|
|
dataZoom: [
|
|
{ type: 'slider', start: 0 },
|
|
'invalid',
|
|
{ type: 'inside' }
|
|
]
|
|
}`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
expect(
|
|
(error as EChartOptionsParseError).validationErrors.some(e =>
|
|
e.includes('dataZoom'),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions validates tooltip properties', () => {
|
|
const input = `{
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
show: true,
|
|
padding: 10
|
|
}
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.tooltip).toEqual({
|
|
trigger: 'axis',
|
|
show: true,
|
|
padding: 10,
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions validates xAxis type property', () => {
|
|
const input = `{
|
|
xAxis: {
|
|
type: 'category',
|
|
name: 'X Axis',
|
|
axisLabel: {
|
|
rotate: 45,
|
|
fontSize: 12
|
|
}
|
|
}
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.xAxis).toEqual({
|
|
type: 'category',
|
|
name: 'X Axis',
|
|
axisLabel: {
|
|
rotate: 45,
|
|
fontSize: 12,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('parseEChartOptions throws when number is used where string expected', () => {
|
|
// backgroundColor should be string, not number
|
|
const input = `{ backgroundColor: 123 }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('parseEChartOptions accepts valid animation options', () => {
|
|
const input = `{
|
|
animation: true,
|
|
animationDuration: 1000,
|
|
animationEasing: 'cubicOut',
|
|
animationDelay: 100
|
|
}`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
animation: true,
|
|
animationDuration: 1000,
|
|
animationEasing: 'cubicOut',
|
|
animationDelay: 100,
|
|
});
|
|
});
|
|
|
|
test('EChartOptionsParseError contains validation error details', () => {
|
|
const input = `{ title: 'invalid', grid: 123 }`;
|
|
|
|
expect.assertions(5);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(EChartOptionsParseError);
|
|
const parseError = error as EChartOptionsParseError;
|
|
expect(parseError.errorType).toBe('validation_error');
|
|
expect(parseError.validationErrors.length).toBe(2);
|
|
expect(parseError.validationErrors.some(e => e.includes('title'))).toBe(
|
|
true,
|
|
);
|
|
expect(parseError.validationErrors.some(e => e.includes('grid'))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// Creator-authored options must not reach the tooltip's innerHTML/
|
|
// navigation sinks unsanitized.
|
|
// =============================================================================
|
|
|
|
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
|
|
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
|
|
});
|
|
|
|
test('keeps presentational tags in tooltip string formatters', () => {
|
|
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
|
|
});
|
|
|
|
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
|
|
const result = parseEChartOptions(
|
|
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ series: [{ type: 'line' }] });
|
|
});
|
|
|
|
test('accepts markup-free tooltip placeholder formatters', () => {
|
|
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
|
|
const result = parseEChartOptions(input);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
|
|
});
|
|
|
|
test('rejects javascript: URLs in title link and sublink', () => {
|
|
expect(() =>
|
|
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
|
|
).toThrow(EChartOptionsParseError);
|
|
expect(() =>
|
|
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
|
|
).toThrow(EChartOptionsParseError);
|
|
expect(() =>
|
|
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
|
|
).toThrow(EChartOptionsParseError);
|
|
});
|
|
|
|
test('accepts http(s) and same-origin relative title links', () => {
|
|
const result = parseEChartOptions(
|
|
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
|
|
});
|
|
});
|
|
|
|
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
|
|
const result = parseEChartOptions(
|
|
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ tooltip: { show: true } });
|
|
});
|
|
|
|
test('accepts a decal pattern on itemStyle', () => {
|
|
const result = parseEChartOptions(
|
|
`{ series: { itemStyle: { decal: {
|
|
symbol: 'rect',
|
|
dashArrayX: [1, 0],
|
|
dashArrayY: [2, 4],
|
|
rotation: -0.7853981633974483,
|
|
color: 'rgba(0, 0, 0, 0.2)',
|
|
} } } }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
series: {
|
|
itemStyle: {
|
|
decal: {
|
|
symbol: 'rect',
|
|
dashArrayX: [1, 0],
|
|
dashArrayY: [2, 4],
|
|
rotation: -0.7853981633974483,
|
|
color: 'rgba(0, 0, 0, 0.2)',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('accepts the full decal shape, including per-row dash arrays', () => {
|
|
// `dashArrayX` nests one level to offset rows from each other; `dashArrayY`
|
|
// has no equivalent.
|
|
const result = parseEChartOptions(
|
|
`{ series: { itemStyle: { decal: {
|
|
symbol: ['rect', 'circle'],
|
|
symbolSize: 0.8,
|
|
symbolKeepAspect: false,
|
|
color: '#383838',
|
|
backgroundColor: 'transparent',
|
|
dashArrayX: [[1, 0], [0, 1]],
|
|
dashArrayY: 5,
|
|
rotation: 0,
|
|
maxTileWidth: 512,
|
|
maxTileHeight: 512,
|
|
} } } }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(
|
|
(result.data as { series: { itemStyle: { decal: unknown } } }).series
|
|
.itemStyle.decal,
|
|
).toEqual({
|
|
symbol: ['rect', 'circle'],
|
|
symbolSize: 0.8,
|
|
symbolKeepAspect: false,
|
|
color: '#383838',
|
|
backgroundColor: 'transparent',
|
|
dashArrayX: [
|
|
[1, 0],
|
|
[0, 1],
|
|
],
|
|
dashArrayY: 5,
|
|
rotation: 0,
|
|
maxTileWidth: 512,
|
|
maxTileHeight: 512,
|
|
});
|
|
});
|
|
|
|
test('strips unknown keys from a decal rather than passing them through', () => {
|
|
const result = parseEChartOptions(
|
|
`{ series: { itemStyle: { decal: { symbol: 'rect', onclick: 'alert(1)' } } } }`,
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
series: { itemStyle: { decal: { symbol: 'rect' } } },
|
|
});
|
|
});
|
|
|
|
test('rejects a decal whose values are of the wrong type', () => {
|
|
// Unknown keys are stripped; a known key with a bad type is an error.
|
|
const input = `{ series: { itemStyle: { decal: { rotation: 'sideways' } } } }`;
|
|
|
|
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
|
|
try {
|
|
parseEChartOptions(input);
|
|
} catch (error) {
|
|
expect((error as EChartOptionsParseError).errorType).toBe(
|
|
'validation_error',
|
|
);
|
|
}
|
|
});
|