@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
1,372 lines (1,251 loc) • 42.5 kB
text/typescript
import {
clientContextValidation,
dataModelsValidation,
embeddableValidation,
formatIssue,
securityContextValidation,
} from "./validate";
import * as fs from "node:fs/promises";
const startMock = {
succeed: vi.fn(),
fail: vi.fn(),
};
vi.mock("@embeddable.com/sdk-utils", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@embeddable.com/sdk-utils")>();
return {
...actual,
errorFormatter: vi.fn((issues) =>
issues.map((issue: any) => issue.message || "Validation error"),
),
findFiles: vi.fn(),
};
});
const failMock = vi.fn();
const validYaml = `cubes:
- name: customers
title: My customers
data_source: default
sql_table: public.customers
dimensions:
- name: id
sql: id
type: number
primary_key: true`;
const invalidYaml = `${validYaml}
measures:
- name: count
type: count
title: '# of customers'
- name: test
type: number
sql: {count} / 10.0`;
const securityContextYaml = `
- name: Example customer 1
securityContext:
country: United States
environment: default`;
const clientContextYaml = `
- name: blue
clientContext:
color: blue`;
const clientContextWithVariablesYaml = `
- name: US
clientContext:
theme: default
locale: en-US
variables:
currency: USD
country: United States`;
const clientContextWithEmptyVariablesYaml = `
- name: UK
clientContext:
theme: default
locale: en-GB
variables: {}`;
const clientContextWithVariousVariableTypesYaml = `
- name: Complex
clientContext:
theme: dark
variables:
stringVar: "hello"
numberVar: 42
booleanVar: true
arrayVar: [1, 2, 3]
objectVar:
nested: "value"`;
vi.mock("ora", () => ({
default: () => ({
start: vi.fn().mockImplementation(() => startMock),
info: vi.fn(),
fail: failMock,
}),
}));
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
access: vi.fn(),
mkdir: vi.fn(),
}));
describe("validate", () => {
describe("dataModelsValidation", () => {
it("should return an empty array if the data models are valid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return validYaml;
});
const filesList: [string, string][] = [
["valid-cube.yaml", "path/to/file"],
];
const result = await dataModelsValidation(filesList);
expect(result).toEqual([]);
});
it("should return an array of error messages if the data models are invalid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return "";
});
const filesList: [string, string][] = [
["invalid-cube.yaml", "path/to/file"],
];
const result = await dataModelsValidation(filesList);
expect(result).toEqual([
"path/to/file: At least one cubes or views must be defined",
]);
});
it("should return an array of error messages if the data models parsing fails", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return invalidYaml;
});
const filesList: [string, string][] = [
["invalid-cube.yaml", "path/to/file"],
];
const result = await dataModelsValidation(filesList);
expect(result).toMatchInlineSnapshot(`
[
"path/to/file: Unexpected scalar at node end at line 18, column 22:
sql: {count} / 10.0
^^^^^^
",
]
`);
});
});
describe("securityContextValidation", () => {
it("should return an empty array if the security context is valid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return securityContextYaml;
});
const filesList: [string, string][] = [
["valid-security-context.json", "path/to/file"],
];
const result = await securityContextValidation(filesList);
expect(result).toEqual([]);
});
it("should return an array of error messages if the security context is invalid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return `${securityContextYaml} ${securityContextYaml}`;
});
const filesList: [string, string][] = [
["invalid-security-context.json", "path/to/file"],
];
const result = await securityContextValidation(filesList);
expect(result).toEqual([
'path/to/file: security context with name "Example customer 1" already exists',
]);
});
});
describe("clientContextValidation", () => {
it("should return an empty array if the client context is valid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return clientContextYaml;
});
const filesList: [string, string][] = [
["valid-client-context.json", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should return an array of error messages if the client context is invalid", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return `${clientContextYaml} ${clientContextYaml}`;
});
const filesList: [string, string][] = [
["invalid-client-context.json", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([
'path/to/file: client context with name "blue" already exists',
]);
});
it("should validate client context with variables field", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return clientContextWithVariablesYaml;
});
const filesList: [string, string][] = [
["valid-client-context-with-variables.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context with empty variables object", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return clientContextWithEmptyVariablesYaml;
});
const filesList: [string, string][] = [
["valid-client-context-empty-variables.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context with various variable types", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return clientContextWithVariousVariableTypesYaml;
});
const filesList: [string, string][] = [
["valid-client-context-various-types.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context without variables field (backward compatibility)", async () => {
vi.mocked(fs.readFile).mockImplementation(async () => {
return clientContextYaml;
});
const filesList: [string, string][] = [
["valid-client-context-no-variables.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should reject client context with variables as non-object type", async () => {
const invalidYaml = `
- name: Invalid
clientContext:
theme: default
variables: "not an object"`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return invalidYaml;
});
const filesList: [string, string][] = [
["invalid-variables-type.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject client context with variables as array", async () => {
const invalidYaml = `
- name: Invalid
clientContext:
theme: default
variables:
- item1
- item2`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return invalidYaml;
});
const filesList: [string, string][] = [
["invalid-variables-array.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should validate client context with variables containing null values", async () => {
const yamlWithNull = `
- name: WithNull
clientContext:
theme: default
variables:
nullableVar: null
stringVar: "value"`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return yamlWithNull;
});
const filesList: [string, string][] = [
["valid-with-null.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate multiple client contexts with variables in same file", async () => {
const multipleContextsYaml = `
- name: US
clientContext:
theme: default
variables:
currency: USD
- name: UK
clientContext:
theme: default
variables:
currency: GBP`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return multipleContextsYaml;
});
const filesList: [string, string][] = [
["multiple-contexts.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context with variables having special characters in keys", async () => {
const yamlWithSpecialKeys = `
- name: SpecialKeys
clientContext:
theme: default
variables:
"key-with-dashes": "value1"
"key_with_underscores": "value2"
"key.with.dots": "value3"
"key with spaces": "value4"`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return yamlWithSpecialKeys;
});
const filesList: [string, string][] = [
["special-keys.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context with deeply nested variables", async () => {
const yamlWithNested = `
- name: Nested
clientContext:
theme: default
variables:
level1:
level2:
level3: "deep value"
array: [1, 2, 3]
simple: "value"`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return yamlWithNested;
});
const filesList: [string, string][] = [
["nested-variables.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
it("should validate client context with variables and canvas together", async () => {
const yamlWithBoth = `
- name: Complete
clientContext:
theme: default
variables:
currency: USD
canvas:
width: 800`;
vi.mocked(fs.readFile).mockImplementation(async () => {
return yamlWithBoth;
});
const filesList: [string, string][] = [
["complete-context.yaml", "path/to/file"],
];
const result = await clientContextValidation(filesList);
expect(result).toEqual([]);
});
});
describe("embeddableValidation", () => {
const validEmbeddableYaml = `
embeddables:
- name: my-embeddable
title: My Embeddable
variables:
- name: date-range
type: timeRange
array: false
defaultValue:
from: "2024-01-01"
to: "2024-12-31"
datasets:
- name: filtered data
model: daily_listens
filters:
- member: daily_listens.age_group
operator: equals
value: date-range
valueType: VARIABLE
widgets:
- component: MultiSelectFieldPro
position:
x: 0
y: 3
dimensions:
width: 7
height: 3
inputs:
- input: title
inputType: string
value: "Age Group"
valueType: VALUE
- input: ds
inputType: dataset
valueType: VALUE
value: filtered data
events:
- event: onChange
action: SET_VARIABLE
config:
variable: date-range
sourceType: EVENT_PROPERTY
sourceValue: value`;
const minimalEmbeddableYaml = `
embeddables:
- name: minimal-dash
title: Minimal`;
it("should return an empty array for a valid embeddable file", async () => {
vi.mocked(fs.readFile).mockImplementation(
async () => validEmbeddableYaml,
);
const filesList: [string, string][] = [
["test.embeddable.yaml", "path/to/test.embeddable.yaml"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should return an empty array for a minimal embeddable file", async () => {
vi.mocked(fs.readFile).mockImplementation(
async () => minimalEmbeddableYaml,
);
const filesList: [string, string][] = [
["test.embeddable.yaml", "path/to/test.embeddable.yaml"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should reject missing root embeddables key", async () => {
const yaml = `
name: bad
title: Bad`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject empty embeddables array", async () => {
const yaml = `
embeddables: []`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["empty.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject unrecognised top-level keys", async () => {
const yaml = `
embeddables:
- name: test
title: Test
unknownKey: bad`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject unrecognised keys on an embeddable item", async () => {
const yaml = `
embeddables:
- name: test
title: Test
bogus: true`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should detect duplicate embeddable names within a file", async () => {
const yaml = `
embeddables:
- name: duplicate-name
title: First
- name: duplicate-name
title: Second`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["dup.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: embeddable with name "duplicate-name" already exists',
]);
});
it("should detect duplicate embeddable names across files", async () => {
const yaml1 = `
embeddables:
- name: shared-name
title: File One`;
const yaml2 = `
embeddables:
- name: shared-name
title: File Two`;
let callCount = 0;
vi.mocked(fs.readFile).mockImplementation(async () => {
return callCount++ === 0 ? yaml1 : yaml2;
});
const filesList: [string, string][] = [
["a.embeddable.yaml", "path/to/a.embeddable.yaml"],
["b.embeddable.yaml", "path/to/b.embeddable.yaml"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/b.embeddable.yaml: embeddable with name "shared-name" already exists',
]);
});
it("should reject invalid valueType enum values", async () => {
const yaml = `
embeddables:
- name: test
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
inputs:
- input: foo
inputType: string
value: bar
valueType: INVALID`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject invalid action enum values", async () => {
const yaml = `
embeddables:
- name: test
variables:
- name: v1
type: string
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
events:
- event: onClick
action: INVALID_ACTION
config:
variable: v1
sourceType: EVENT_PROPERTY
sourceValue: val`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should reject invalid sourceType enum values", async () => {
const yaml = `
embeddables:
- name: test
variables:
- name: v1
type: string
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
events:
- event: onClick
action: SET_VARIABLE
config:
variable: v1
sourceType: BADTYPE
sourceValue: val`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should detect dataset filter referencing undefined variable", async () => {
const yaml = `
embeddables:
- name: test
variables:
- name: existing-var
type: string
datasets:
- name: ds1
model: my_model
filters:
- member: my_model.field
operator: equals
value: nonexistent-var
valueType: VARIABLE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: dataset "ds1" references undefined variable "nonexistent-var"',
]);
});
it("should not flag dataset filter with valueType VALUE", async () => {
const yaml = `
embeddables:
- name: test
datasets:
- name: ds1
model: my_model
filters:
- member: my_model.field
operator: equals
value: some-literal
valueType: VALUE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ok.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should detect widget input referencing undefined variable", async () => {
const yaml = `
embeddables:
- name: test
variables:
- name: known-var
type: string
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
inputs:
- input: myInput
inputType: string
value: unknown-var
valueType: VARIABLE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: widget "Comp" input "myInput" references undefined variable "unknown-var"',
]);
});
it("should detect widget input referencing undefined dataset", async () => {
const yaml = `
embeddables:
- name: test
datasets:
- name: real-dataset
model: my_model
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
inputs:
- input: ds
inputType: dataset
value: fake-dataset
valueType: VALUE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: widget "Comp" input "ds" references undefined dataset "fake-dataset"',
]);
});
it("should detect SET_VARIABLE event referencing undefined variable", async () => {
const yaml = `
embeddables:
- name: test
variables:
- name: real-var
type: string
widgets:
- component: Comp
position: { x: 0, y: 0 }
dimensions: { width: 1, height: 1 }
events:
- event: onChange
action: SET_VARIABLE
config:
variable: missing-var
sourceType: EVENT_PROPERTY
sourceValue: value`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: widget "Comp" event "onChange" references undefined variable "missing-var"',
]);
});
it("should detect overlapping widgets in an embeddable", async () => {
const yaml = `
embeddables:
- name: Test-webc
widgets:
- component: DateRangePickerCustomPro
position: { x: 0, y: 0 }
dimensions: { width: 4, height: 7 }
- component: BarChartDefaultPro
position: { x: 0, y: 2 }
dimensions: { width: 12, height: 15 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["overlap.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: embeddable "Test-webc" widgets "DateRangePickerCustomPro" and "BarChartDefaultPro" overlap. "DateRangePickerCustomPro" occupies x 0-4, y 0-7; "BarChartDefaultPro" occupies x 0-12, y 2-17.',
]);
});
it("should allow widgets that touch edges without overlapping", async () => {
const yaml = `
embeddables:
- name: Test-webc
widgets:
- component: DateRangePickerCustomPro
position: { x: 0, y: 0 }
dimensions: { width: 4, height: 7 }
- component: BarChartDefaultPro
position: { x: 0, y: 7 }
dimensions: { width: 12, height: 15 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["valid.embeddable.yaml", "path/to/file"],
];
const result = await embeddableValidation(filesList);
expect(result).toEqual([]);
});
it("should ignore overlap checks for widgets with non-positive dimensions", async () => {
const yaml = `
embeddables:
- name: Test-webc
widgets:
- component: EmptyWidthWidget
position: { x: 0, y: 0 }
dimensions: { width: 0, height: 7 }
- component: BarChartDefaultPro
position: { x: 0, y: 2 }
dimensions: { width: 12, height: 15 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["non-positive-dimensions.embeddable.yaml", "path/to/file"],
];
const result = await embeddableValidation(filesList);
expect(result).toEqual([]);
});
it("should detect customCanvas referencing undefined dataset", async () => {
const yaml = `
embeddables:
- name: test
datasets:
- name: real-ds
model: my_model
customCanvas:
datasets:
- dataset: bogus-ds`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: customCanvas references undefined dataset "bogus-ds"',
]);
});
it("should reject template without key", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- name: Real Template
component: SomeComp`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["nokey.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
expect(result[0]).toContain("key");
});
it("should detect duplicate template keys", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: shared
name: First
component: CompA
- key: shared
name: Second
component: CompB`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["dupkey.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: customCanvas has duplicate template key "shared"',
]);
});
it("should resolve starterCanvas reference by template key, not name", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: my-template
name: Friendly Display Name
component: SomeComp
starterCanvas:
widgets:
- template: my-template
dimensions: { width: 7, height: 3 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bykey.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should detect starterCanvas referencing template by name instead of key", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: my-template
name: Friendly Display Name
component: SomeComp
starterCanvas:
widgets:
- template: Friendly Display Name
dimensions: { width: 7, height: 3 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["byname.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: starterCanvas references undefined template "Friendly Display Name"',
]);
});
it("should detect starterCanvas referencing undefined template", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: real-template
name: Real Template
component: SomeComp
starterCanvas:
widgets:
- template: Missing Template
dimensions: { width: 7, height: 3 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["ref.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([
'path/to/file: starterCanvas references undefined template "Missing Template"',
]);
});
it("should handle YAML parse errors gracefully", async () => {
vi.mocked(fs.readFile).mockImplementation(
async () => ":\ninvalid: [yaml: {{{",
);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should validate a full example with customCanvas and templates", async () => {
const yaml = `
embeddables:
- name: full-example
title: Full Example
variables:
- name: country
type: string
datasets:
- name: main-data
model: users
widgets:
- component: Table
position: { x: 0, y: 0 }
dimensions: { width: 12, height: 6 }
inputs:
- input: ds
inputType: dataset
value: main-data
valueType: VALUE
events:
- event: onClick
action: DRILLDOWN
config:
embeddable: detail-view
variableOverrides:
- variable: country
sourceType: EVENT_PROPERTY
sourceValue: chosenItem
customCanvas:
datasets:
- dataset: main-data
templates:
- key: bar-chart
name: Bar Chart
component: BarComp
description: A bar chart
icon: bar_chart
inputs:
- input: ds
value: main-data
valueType: VALUE
starterCanvas:
widgets:
- template: bar-chart
dimensions: { width: 6, height: 4 }`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["full.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should accept array: true on a template input", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: my-chart
name: My Chart
component: ChartComp
inputs:
- input: ds
value: some-ds
array: true`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["array.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should accept visible on a template input", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: my-chart
name: My Chart
component: ChartComp
inputs:
- input: ds
value: some-ds
visible: false
- input: title
value: Hello
visible: true`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["visible.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should reject non-boolean visible on a template input", async () => {
const yaml = `
embeddables:
- name: test
customCanvas:
templates:
- key: my-chart
name: My Chart
component: ChartComp
inputs:
- input: ds
value: some-ds
visible: "yes"`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["visible-bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should validate dataset filter with input config containing order", async () => {
const yaml = `
embeddables:
- name: test
datasets:
- name: ds1
model: my_model
widgets:
- component: Table
position: { x: 0, y: 0 }
dimensions: { width: 12, height: 6 }
inputs:
- input: ds
inputType: dataset
value: ds1
valueType: VALUE
config:
filters: []
limit: 10
order:
- member: my_model.field
direction: asc`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["config.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should reject invalid order direction", async () => {
const yaml = `
embeddables:
- name: test
widgets:
- component: Table
position: { x: 0, y: 0 }
dimensions: { width: 12, height: 6 }
inputs:
- input: ds
inputType: dataset
value: ds1
valueType: VALUE
config:
order:
- member: my_model.field
direction: sideways`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
});
it("should validate nested config inputs on widgets", async () => {
const yaml = `
embeddables:
- name: test
widgets:
- component: DimField
position: { x: 0, y: 0 }
dimensions: { width: 4, height: 2 }
inputs:
- input: dim
inputType: dimension
value: my_model.field
valueType: VALUE
config:
inputs:
- input: suffix
value: some suffix
valueType: VALUE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["nested.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should validate input with array flag and config.dataset with parentValue on nested inputs", async () => {
const yaml = `
embeddables:
- name: my-embeddable-new
title: My Embeddable updated
variables:
- name: date-range
type: timeRange
array: false
defaultValue: { relativeTimeString: 'Last 30 days' }
datasets:
- name: myDataset
model: orders
widgets:
- component: BarChartDefaultPro
position: { x: 0, y: 2 }
dimensions: { width: 12, height: 15 }
inputs:
- input: dataset
inputType: dataset
valueType: VALUE
value: myDataset
- input: measures
inputType: measure
valueType: VALUE
array: true
value:
- customers.count
- orders.count
config:
dataset: dataset
inputs:
- input: prefix
value: "Test 2"
parentValue: customers.count
valueType: VALUE
- input: suffix
value: "Suf 1"
parentValue: orders.count
valueType: VALUE
- input: dimension
inputType: dimension
valueType: VALUE
value: orders.created_at
config:
dataset: dataset
inputs:
- input: granularity
value: "month"
valueType: VALUE`;
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["array-parent.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should accept valid operation values and reject invalid ones on variables", async () => {
const validYaml = `
embeddables:
- name: test
variables:
- name: v1
type: string
operation: NO_FILTER
- name: v2
type: string
operation: VALUE`;
vi.mocked(fs.readFile).mockImplementation(async () => validYaml);
const filesList: [string, string][] = [
["test.embeddable.yaml", "path/to/file"],
];
expect((await embeddableValidation(filesList)).map(formatIssue)).toEqual(
[],
);
const invalidYaml = `
embeddables:
- name: test
variables:
- name: v1
type: string
operation: INVALID`;
vi.mocked(fs.readFile).mockImplementation(async () => invalidYaml);
const errors = (await embeddableValidation(filesList)).map(formatIssue);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain("path/to/file");
});
it("should return no errors for an empty file list", async () => {
const filesList: [string, string][] = [];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result).toEqual([]);
});
it("should reference embeddable name instead of index in error messages", async () => {
const yaml = `
embeddables:
- name: My Dashboard
widgets:
- component: BarChart
position: { x: 0, y: 0 }
dimensions: { width: 4, height: 2 }
inputs:
- input: showValueLabel
value: true
valueType: VALUE`;
// inputType is required but missing — should name the embeddable and widget
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["bad.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("Embeddable 'My Dashboard'");
expect(result[0]).toContain("widget 'BarChart'");
expect(result[0]).toContain("input 'showValueLabel'");
expect(result[0]).not.toMatch(/embeddables\[0\]/);
expect(result[0]).not.toMatch(/widgets\[0\]/);
expect(result[0]).not.toMatch(/inputs\[0\]/);
});
it("should reference the correct names when error is on a later index", async () => {
const yaml = `
embeddables:
- name: First Dashboard
widgets:
- component: PieChart
position: { x: 0, y: 0 }
dimensions: { width: 4, height: 2 }
inputs:
- input: title
inputType: string
value: My Title
valueType: VALUE
- input: dataset
inputType: dataset
value: ds1
valueType: VALUE
- input: metricLabel
value: Revenue
valueType: VALUE`;
// inputType missing on the third input (index 2)
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["indexed.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("Embeddable 'First Dashboard'");
expect(result[0]).toContain("widget 'PieChart'");
expect(result[0]).toContain("input 'metricLabel'");
expect(result[0]).not.toMatch(/inputs\[2\]/);
});
it("should fall back to index notation when name fields are absent in raw data", async () => {
// An embeddable with no 'name' field — schema will reject it, but the
// formatter should degrade gracefully (e.g. keep embeddables[0]).
const yaml = `
embeddables:
- title: Unnamed`;
// 'name' is required, so there will be a validation error at embeddables[0]
vi.mocked(fs.readFile).mockImplementation(async () => yaml);
const filesList: [string, string][] = [
["noname.embeddable.yaml", "path/to/file"],
];
const result = (await embeddableValidation(filesList)).map(formatIssue);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain("path/to/file");
// Should not crash — just degrade to index notation
expect(result[0]).toMatch(/embeddables\[0\]/);
});
});
});