Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -91,3 +91,22 @@ describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-ex
|
||||
expect(html).not.toMatch(/visibilitychange/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
|
||||
test('a slide with imageSrc set exports a background-image referencing it', () => {
|
||||
const slidesWithImage = [
|
||||
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithImage }, '');
|
||||
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
|
||||
});
|
||||
|
||||
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
|
||||
const slidesNoImage = [
|
||||
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesNoImage }, '');
|
||||
expect(html).not.toContain('background-image:url(');
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* MediaStylePanel (via useNodeProp/ArrayPropEditor in ./shared) only needs
|
||||
useEditor from @craftjs/core. Mock it following the DOM-harness pattern
|
||||
used in Footer.editguard.test.tsx / AssetPicker.test.tsx (no
|
||||
@testing-library/react in this repo) so setProp calls can be observed
|
||||
without mounting a real <Editor> tree. */
|
||||
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||
updater(lastProps);
|
||||
});
|
||||
let lastProps: any;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/assets', () => ({
|
||||
uploadAsset: vi.fn(),
|
||||
listAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
import { MediaStylePanel } from './MediaStylePanel';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function q<T extends Element = Element>(testId: string): T | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
function setValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPropSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container) unmount();
|
||||
});
|
||||
|
||||
/**
|
||||
* INT: ContentSlider (src/components/sections/ContentSlider.tsx) reads
|
||||
* `slide.imageSrc` in both render and toHtml, but the Slides array editor
|
||||
* here guarded on `item.image !== undefined` and wrote `image` via
|
||||
* AssetPicker's onChange -- a key that ContentSlider never reads. Default
|
||||
* slides (`imageSrc:''`, no `image` key) therefore showed NO image picker at
|
||||
* all (the guard was never true), and even a manually-added `image` value
|
||||
* was a silent no-op on export/render.
|
||||
*/
|
||||
describe('MediaStylePanel Slides editor uses imageSrc (INT)', () => {
|
||||
test('a slide shaped like defaultSlides (imageSrc present, no `image` key) renders the image AssetPicker', () => {
|
||||
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
|
||||
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||
|
||||
// Before the fix, the guard `item.image !== undefined` was false for
|
||||
// this default-shaped slide, so no AssetPicker rendered at all.
|
||||
expect(q('asset-picker-compact')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('editing the slide image writes imageSrc (not image) via setProp', () => {
|
||||
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
|
||||
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||
|
||||
const urlInput = q<HTMLInputElement>('asset-picker-url-input')!;
|
||||
expect(urlInput).not.toBeNull();
|
||||
setValue(urlInput, 'https://example.com/slide.jpg');
|
||||
act(() => {
|
||||
urlInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
const updatedSlide = lastProps.slides[0];
|
||||
expect(updatedSlide.imageSrc).toBe('https://example.com/slide.jpg');
|
||||
expect(updatedSlide.image).toBeUndefined();
|
||||
});
|
||||
|
||||
test('adding a new slide (emptyItem) includes imageSrc, matching defaultSlides shape', () => {
|
||||
lastProps = { slides: [] };
|
||||
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||
|
||||
// The "+ Add Item" button for the Slides ArrayPropEditor.
|
||||
const addButtons = Array.from(container.querySelectorAll('button')).filter(
|
||||
(b) => b.textContent?.includes('Add Item'),
|
||||
);
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
act(() => {
|
||||
addButtons[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastProps.slides).toHaveLength(1);
|
||||
expect(lastProps.slides[0]).toHaveProperty('imageSrc');
|
||||
expect(lastProps.slides[0].image).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -145,14 +145,14 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
});
|
||||
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||
)}
|
||||
{item.image !== undefined && (
|
||||
{item.imageSrc !== undefined && (
|
||||
<AssetPicker
|
||||
variant="compact"
|
||||
value={item.image || ''}
|
||||
value={item.imageSrc || ''}
|
||||
onChange={(url) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.slides || [])];
|
||||
updated[index] = { ...updated[index], image: url };
|
||||
updated[index] = { ...updated[index], imageSrc: url };
|
||||
props.slides = updated;
|
||||
});
|
||||
}}
|
||||
@@ -160,7 +160,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ heading: 'New Slide', text: '', image: '' }}
|
||||
emptyItem={{ type: 'image', imageSrc: '', heading: 'New Slide', text: '', bgColor: '' }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user