@silexlabs/grapesjs-data-source
Version:
Grapesjs Data Source
504 lines (435 loc) • 16.3 kB
text/typescript
/**
* @jest-environment jsdom
*/
import { jest } from '@jest/globals'
import fs from 'fs'
import path from 'path'
import grapesjs from 'grapesjs'
import plugin from './index'
import { Type, Field, DataSourceType } from './types'
import { addDataSource } from './api'
import { GQLField, GQLType } from './datasources/GraphQL'
import { FieldKind, IDataSource } from '../dist'
import { setPreviewData } from './api'
import { compare, GroupingReporter } from 'dom-compare'
import { diff as jestDiff } from 'jest-diff'
// ////
// Use require instead of import so the TextEncoder/TextDecoder polyfill is set before jsdom loads (avoids hoisting).
/* @ts-expect-error Workaround jest+jsdom bug */
import { TextEncoder, TextDecoder } from 'util'
import { doRender } from './view/canvas'
;import { act } from 'react'
(global as any).TextEncoder = TextEncoder
;(global as any).TextDecoder = TextDecoder
const { JSDOM } = require('jsdom')
// ////
const TESTS_PATH = '/integration-tests'
// tests/setupJest.ts (setupFilesAfterEnv dans Jest)
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get() { return window },
})
Object.defineProperty(HTMLIFrameElement.prototype, 'contentDocument', {
get() { return document },
})
// Quand un iframe est ajouté au DOM, on simule son "load"
const origAppend = Element.prototype.appendChild
Element.prototype.appendChild = function (child: unknown) {
const res = origAppend.call(this, child)
if (child && child.tagName === 'IFRAME') {
// microtask → simulateur de chargement
Promise.resolve().then(() => {
child.dispatchEvent(new Event('load'))
})
}
return res
}
// Mock lit to avoid ES module import issues
jest.mock('lit', () => ({
html: jest.fn((strings, ...values) => ({ strings, values })),
render: jest.fn(),
}))
// Mock UI components that use decorators
jest.mock('./view/settings', () => jest.fn((editor, options) => {
// Mock settings function that does nothing
}))
jest.mock('./view/custom-states-editor', () => ({
CustomStatesEditor: jest.fn()
}))
jest.mock('./view/properties-editor', () => ({
PropertiesEditor: jest.fn()
}))
// Mock module that doesn't exist
jest.mock('@silexlabs/expression-input', () => ({}), { virtual: true })
// Mock data source inspirée de EleventyDataSource
class MockDataSource implements IDataSource {
/**
* Unique identifier of the data source
* This is used to retrieve the data source from the editor
*/
public id = 'MockDataSourceId'
public label = 'MockDataSourceLabel'
public url = ''
public type = 'graphql' as DataSourceType
public method = 'POST'
public headers = {}
public hidden = true
public readonly = true
private mockData: { data: unknown[] }
private mockTypes: {data: {__schema: {types: GQLType[], queryType: {name: string}}}}
private queryType: string = ''
private eventListeners: Record<string, ((...args: unknown[]) => void)[]> = {}
constructor(id: string, mockTypes: {data: {__schema: {types: GQLType[], queryType: {name: string}}}}, mockData: { data: unknown[] }) {
this.mockTypes = mockTypes
this.mockData = mockData
this.queryType = mockTypes.data.__schema.queryType.name
this.id = id
}
// Simple event handling
on(event: string, callback: (...args: unknown[]) => void): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = []
}
this.eventListeners[event].push(callback)
}
off(event: string, callback?: (...args: unknown[]) => void): void {
if (!this.eventListeners[event]) return
if (callback) {
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback)
} else {
this.eventListeners[event] = []
}
}
trigger(event: string, ...args: unknown[]): void {
if (!this.eventListeners[event]) return
this.eventListeners[event].forEach(callback => callback(...args))
}
/**
* Implement IDatasource
*/
async connect(): Promise<void> {}
isConnected(): boolean { return true }
/**
* Implement IDatasource
*/
getQuery(/*expressions: Expression[]*/): string { return '' }
/**
* Implement IDatasource
*/
getTypes(): Type[] {
// Built-in scalar types for GraphQL
const builtinTypes: Type[] = [
{ id: 'String', label: 'String', fields: [], dataSourceId: this.id },
{ id: 'Int', label: 'Int', fields: [], dataSourceId: this.id },
{ id: 'ID', label: 'ID', fields: [], dataSourceId: this.id },
{ id: 'Boolean', label: 'Boolean', fields: [], dataSourceId: this.id },
{ id: 'Float', label: 'Float', fields: [], dataSourceId: this.id },
]
// Include ALL types from the schema, not just query fields
const allTypes = this.mockTypes.data.__schema.types
.filter((type: GQLType) =>
type.name &&
!type.name.startsWith('__') && // Skip introspection types
type.kind === 'OBJECT' // Only include object types for now
)
.map((type: GQLType) => {
const fields = type.fields
? type.fields.map((f: GQLField) => ({
id: f.name,
label: f.name,
typeIds: [f.type.name || f.type.ofType?.name || 'Unknown'],
kind: (f.type.kind || '').toLowerCase() as FieldKind,
dataSourceId: this.id,
}))
: []
return {
id: type.name,
label: type.name,
fields,
dataSourceId: this.id,
}
})
return [...builtinTypes, ...allTypes]
}
// getTypes(): Type[] {
// return [{
// id: 'string',
// label: 'String',
// dataSourceId: 'eleventy',
// fields: [],
// }, {
// id: 'locale_link',
// label: 'locale_link',
// dataSourceId: 'eleventy',
// fields: [{
// id: 'url',
// label: 'url',
// typeIds: ['string'],
// kind: 'scalar',
// dataSourceId: 'eleventy',
// }, {
// id: 'lang',
// label: 'lang',
// typeIds: ['string'],
// kind: 'scalar',
// dataSourceId: 'eleventy',
// }, {
// id: 'label',
// label: 'label',
// typeIds: ['string'],
// kind: 'scalar',
// dataSourceId: 'eleventy',
// }],
// }]
// }
getQueryables(): Field[] {
const query = this.mockTypes.data.__schema.types
.find((type: GQLType) => type.name === this.queryType)
return query.fields
}
// getQueryables(): Field[] {
// return [{
// id: 'page',
// label: 'page',
// typeIds: ['page'],
// kind: 'object',
// dataSourceId: 'eleventy',
// //}, {
// // id: 'eleventy',
// // label: 'eleventy',
// // typeIds: ['eleventy'],
// // kind: 'object',
// // dataSourceId: 'eleventy',
// //}, {
// // id: 'env',
// // label: 'env',
// // typeIds: ['env'],
// // kind: 'object',
// // dataSourceId: 'eleventy',
// }]
// }
fetchValues(query: string): Promise<unknown[]> {
console.log('MockDataSource fetchValues called with:', query)
return Promise.resolve(this.mockData.data)
}
}
// HTML normalization for comparison
function normalizeHtml(html: string): string {
return html
.replace(/\s+/g, ' ')
.replace(/>\s+</g, '><')
.trim()
}
// Get all test case directories
function getTestCaseDirectories(): string[] {
const sampleDataPath = path.join(__dirname, '..', TESTS_PATH)
if (!fs.existsSync(sampleDataPath)) {
throw new Error(`Sample data not found, ${sampleDataPath} doesn't exist`)
}
return fs.readdirSync(sampleDataPath, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
.filter(dirName => {
const testCasePath = path.join(sampleDataPath, dirName)
// Check if directory contains all required files
return fs.existsSync(path.join(testCasePath, 'graphql-response.json')) &&
fs.existsSync(path.join(testCasePath, 'website.json')) &&
fs.existsSync(path.join(testCasePath, 'preview.html'))
})
}
// Load test case data
function loadTestCase(testCaseName: string) {
const testCasePath = path.join(__dirname, '..', TESTS_PATH, testCaseName)
const graphqlTypes = JSON.parse(
fs.readFileSync(path.join(testCasePath, 'graphql-types.json'), 'utf8')
)
const graphqlResponse = JSON.parse(
fs.readFileSync(path.join(testCasePath, 'graphql-response.json'), 'utf8')
)
const website = JSON.parse(
fs.readFileSync(path.join(testCasePath, 'website.json'), 'utf8')
)
const expectedHtml = normalizeHtml(fs.readFileSync(
path.join(testCasePath, 'preview.html'), 'utf8'
))
const dataSourceId = (website.dataSources[0] || {}).id || 'countries'
return { dataSourceId, graphqlTypes, graphqlResponse, website, expectedHtml }
}
/** --- tiny helpers (no extra deps) --- */
function parseBody(html: string): HTMLElement {
// Prefer the jsdom test env DOMParser if available; fallback to JSDOM
if (typeof DOMParser !== 'undefined') {
return new DOMParser().parseFromString(html, 'text/html').body as unknown as HTMLElement
}
return new JSDOM(html).window.document.body as unknown as HTMLElement
}
// Normalize: trim text nodes, collapse spaces, sort class names
function normalizeDom(root: HTMLElement) {
const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT)
const toNormalize: Node[] = []
while (walker.nextNode()) toNormalize.push(walker.currentNode)
for (const node of toNormalize) {
if (node.nodeType === Node.TEXT_NODE) {
node.nodeValue = (node.nodeValue || '').replace(/\s+/g, ' ').trim()
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element
// sort class names (ignore order-only diffs)
if (el.hasAttribute('class')) {
const cls = el.getAttribute('class')!
const sorted = cls.split(/\s+/).filter(Boolean).sort().join(' ')
el.setAttribute('class', sorted)
}
// normalize attribute whitespace (e.g., title, data-*)
for (const attr of Array.from(el.attributes)) {
el.setAttribute(attr.name, attr.value.replace(/\s+/g, ' ').trim())
}
}
}
}
// very small prettifier - just adds newlines between tags/text runs for better diffs
function pretty(html: string): string {
return html
.replace(/>\s+</g, '><')
.replace(/</g, '\n<')
.replace(/\n+/g, '\n')
.trim()
}
function summarizeGrouped(result: ReturnType<typeof compare>) {
const grouped = GroupingReporter.getDifferences(result) as Record<string, { message: string }[]>
const lines: string[] = []
const PATH_LIMIT = 8
const MSG_LIMIT = 4
const paths = Object.keys(grouped)
for (let i = 0; i < Math.min(paths.length, PATH_LIMIT); i++) {
const path = paths[i]
const msgs = grouped[path]
lines.push(`• ${path}`)
for (let j = 0; j < Math.min(msgs.length, MSG_LIMIT); j++) {
lines.push(` - ${msgs[j].message}`)
}
if (msgs.length > MSG_LIMIT) lines.push(` - …and ${msgs.length - MSG_LIMIT} more at this node`)
}
if (paths.length > PATH_LIMIT) lines.push(`…and ${paths.length - PATH_LIMIT} more nodes differ`)
return lines.join('\n')
}
/** --- drop-in API-compatible function --- */
export function toEqualDom(received: string, expected: string, testCaseName: string) {
const a = parseBody(received).cloneNode(true) as HTMLElement
const b = parseBody(expected).cloneNode(true) as HTMLElement
normalizeDom(a)
normalizeDom(b)
const result = compare(a, b, {
stripSpaces: true,
collapseSpaces: true,
normalizeWhitespace: true,
} as any)
if (result.getResult()) {
return { pass: true, message: 'DOMs are equal' }
}
// grouped summary (clear, low-noise)
const groupedSummary = summarizeGrouped(result)
// compact unified diff of prettified HTML (helps spot exact spot)
const unified = jestDiff(pretty(b.innerHTML), pretty(a.innerHTML), {
expand: false,
contextLines: 1,
}) || ''
const message = `
[${testCaseName}] DOMs differ:
${groupedSummary.substring(0, 5000)}
[${testCaseName}] Unified diff (prettified body HTML):
${unified.substring(0, 5000)}
`
return { pass: false, message }
}
const testCaseDirectories = getTestCaseDirectories()
if (testCaseDirectories?.length === 0) {
throw new Error(`No test cases found in ${TESTS_PATH}. Create test case directories with graphql-response.json, website.json, and preview.html files.`)
}
describe('Integration tests validation', () => {
testCaseDirectories.forEach(testCaseName => {
test(`Validate the data found for ${testCaseName}`, () => {
const { dataSourceId, graphqlTypes, graphqlResponse, website, expectedHtml } = loadTestCase(testCaseName)
expect(Array.isArray(graphqlResponse.data?.continents)).toBe(true)
expect(website.pages).toHaveLength(1)
expect(website.dataSources).toBeDefined()
expect(website.dataSources.length <= 1).toBe(true)
expect(expectedHtml).toMatch(/^<div .*data-gjs-type="wrapper".*>/)
const mockDataSource = new MockDataSource(dataSourceId, graphqlTypes, graphqlResponse)
const types = mockDataSource.getTypes()
expect(Array.isArray(types)).toBe(true)
expect(types.length).toBeGreaterThan(0)
expect(types.flatMap(t => t.fields).find(t => t.typeIds.includes('String'))).toBeTruthy()
const hasFields = types.some(type => Array.isArray(type.fields) && type.fields.length > 0)
expect(hasFields).toBe(true)
const continentType = types.find(type => type.id === 'Continent')
expect(continentType).toBeDefined()
expect(continentType?.fields.some(f => f.id === 'name')).toBe(true)
expect(continentType?.fields.some(f => f.id === 'countries')).toBe(true)
expect(types.find(type => !!type.fields.some(f => f.typeIds.includes('String')))).toBeTruthy()
})
})
})
describe('Integration tests', () => {
testCaseDirectories.forEach(testCaseName => {
test(`Generated preview for ${ testCaseName }`, (done) => {
// Load test data
const { dataSourceId, graphqlTypes, graphqlResponse, website, expectedHtml } = loadTestCase(testCaseName)
const container = document.createElement('div')
document.body.appendChild(container)
// Create GrapesJS editor WITHOUT headless to get real DOM elements
const editor = grapesjs.init({
container,
headless: false,
plugins: [plugin],
pluginsOpts: {
[plugin.toString()]: {
view: {
el : null,
previewRefreshEvents: '', // let me call doRender directly
},
filters: 'liquid',
},
}
})
editor.on('load', () => {
const mockDataSource = new MockDataSource(dataSourceId, graphqlTypes, graphqlResponse)
addDataSource(mockDataSource)
editor.loadProjectData(website)
// Wait longer to account for debounced rendering
setTimeout(() => {
setPreviewData({
[dataSourceId]: graphqlResponse.data,
})
const beforePreview = editor.getWrapper()?.view?.el.outerHTML || ''
doRender(editor)
const actualHtml = editor.getWrapper()?.view?.el.outerHTML || ''
const previewHtmlDir = process.env.PREVIEW_HTML_OUT_DIR
if (previewHtmlDir) {
console.info(`Writting test resuslt to ${path.join(previewHtmlDir, testCaseName + '.html')}`)
fs.writeFileSync(
path.join(previewHtmlDir, testCaseName + '.html'),
actualHtml,
'utf8',
)
}
done()
const compared = toEqualDom(actualHtml, expectedHtml, testCaseName)
if (!compared.pass) {
console.warn(`
${compared.message}
_______________________
before preview: ${beforePreview.substring(0, 500)}
_______________________
expected html: ${expectedHtml.substring(0, 500)}
_______________________
actual html: ${actualHtml.substring(0, 500)}
_______________________
`)
}
expect(compared.pass).toBe(true)
editor.destroy()
container.remove()
}, 100)
})
})
})
})