UNPKG

@silexlabs/grapesjs-data-source

Version:
504 lines (435 loc) 16.3 kB
/** * @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) }) }) }) })