Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,51 @@
langcode: en
status: true
dependencies:
config:
- filter.format.basic_html
# TRICKY: This technically is a module that this config depends on, but it has been removed from Drupal >=10.
# module:
# - ckeditor
format: basic_html
editor: ckeditor
settings:
toolbar:
rows:
-
-
name: Formatting
items:
- Bold
- Italic
-
name: Linking
items:
- DrupalLink
- DrupalUnlink
-
name: Lists
items:
- BulletedList
- NumberedList
-
name: Media
items:
- Blockquote
- DrupalImage
-
name: 'Block Formatting'
items:
- Format
-
name: Tools
items:
- Source
plugins: {}
image_upload:
status: true
scheme: public
directory: inline-images
max_size: null
max_dimensions:
width: null
height: null

View File

@ -0,0 +1,59 @@
langcode: en
status: true
dependencies:
config:
- filter.format.full_html
# TRICKY: This technically is a module that this config depends on, but it has been removed from Drupal >=10.
# module:
# - ckeditor
format: full_html
editor: ckeditor
settings:
toolbar:
rows:
-
-
name: Formatting
items:
- Bold
- Italic
- Strike
- Superscript
- Subscript
- '-'
- RemoveFormat
-
name: Linking
items:
- DrupalLink
- DrupalUnlink
-
name: Lists
items:
- BulletedList
- NumberedList
-
name: Media
items:
- Blockquote
- DrupalImage
- Table
- HorizontalRule
-
name: 'Block Formatting'
items:
- Format
-
name: Tools
items:
- ShowBlocks
- Source
plugins: {}
image_upload:
status: true
scheme: public
directory: inline-images
max_size: null
max_dimensions:
width: null
height: null

View File

@ -0,0 +1,44 @@
langcode: en
status: true
dependencies:
module:
- editor
name: 'Basic HTML'
format: basic_html
weight: 0
roles:
- authenticated
filters:
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }
filter_align:
id: filter_align
provider: filter
status: true
weight: 7
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 8
settings: { }
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <p> <br> <span> <img src alt height width data-entity-type data-entity-uuid data-align data-caption>'
filter_html_help: false
filter_html_nofollow: false
filter_html_image_secure:
id: filter_html_image_secure
provider: filter
status: true
weight: 9
settings: { }

View File

@ -0,0 +1,35 @@
langcode: en
status: true
dependencies:
module:
- editor
name: 'Full HTML'
format: full_html
weight: 2
roles:
- administrator
filters:
filter_align:
id: filter_align
provider: filter
status: true
weight: 8
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 9
settings: { }
filter_htmlcorrector:
id: filter_htmlcorrector
provider: filter
status: true
weight: 10
settings: { }
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }

View File

@ -0,0 +1,31 @@
langcode: en
status: true
dependencies: { }
name: 'Restricted HTML'
format: restricted_html
weight: 1
roles:
- anonymous
filters:
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>'
filter_html_help: true
filter_html_nofollow: false
filter_autop:
id: filter_autop
provider: filter
status: true
weight: 0
settings: { }
filter_url:
id: filter_url
provider: filter
status: true
weight: 0
settings:
filter_url_length: 72

View File

@ -0,0 +1 @@
<svg fill="none" height="16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#ffd23f"><path d="M6.5 1h3v9h-3z"/><circle cx="8" cy="13.5" r="1.5"/></g></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@ -0,0 +1,17 @@
ckeditor5_automatic_link_decorator_test_llamaClass:
ckeditor5:
plugins: []
config:
link:
decorators:
pinkColor:
mode: 'automatic'
attributes:
class: 'llama'
drupal:
label: Links must have 'llama' class!
elements:
- <a class>
conditions:
plugins:
- ckeditor5_link

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Automatic Link Decorator Test
type: module
description: "Provides infrastructure for testing CKEditor 5 automatic link decorators."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,13 @@
ckeditor5_automatic_link_decorator_test_2_addTargetToExternalLinks:
ckeditor5:
plugins: []
config:
link:
addTargetToExternalLinks: true
drupal:
label: Open external links in a new tab
elements:
- <a target="_blank" rel="noopener noreferrer">
conditions:
plugins:
- ckeditor5_link

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Automatic Link Decorator Test (External links)
type: module
description: "Provides infrastructure for testing CKEditor 5 external links automatic link decorator."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,111 @@
ckeditor5_definition_supporting_element_just_nav:
ckeditor5:
plugins: []
drupal:
label: TEST — <nav>
elements:
- <nav>
ckeditor5_definition_supporting_element_just_article:
ckeditor5:
plugins: []
drupal:
label: TEST — <article>
elements:
- <article>
ckeditor5_definition_supporting_element_article_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class>
elements:
- <article class>
ckeditor5_definition_supporting_element_article_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class="…">
elements:
- <article class="this-value that-value">
ckeditor5_definition_supporting_element_just_footer:
ckeditor5:
plugins: []
drupal:
label: TEST — <footer>
elements:
- <footer>
ckeditor5_definition_supporting_element_footer_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <footer class>
elements:
- <footer class>
ckeditor5_definition_supporting_element_just_aside:
ckeditor5:
plugins: []
drupal:
label: TEST — <aside>
elements:
- <aside>
ckeditor5_definition_supporting_element_aside_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <article class="…">
elements:
- <aside class="this-value that-value">
ckeditor5_definition_supporting_element_main_class:
ckeditor5:
plugins: []
drupal:
label: TEST — <main class>
elements:
- <main class>
ckeditor5_definition_supporting_element_main_class_with_values:
ckeditor5:
plugins: []
drupal:
label: TEST — <main class="…">
elements:
- <main class="this-value that-value">
ckeditor5_definition_supporting_element_figure_one_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <figure data-one>
elements:
- <figure data-one>
ckeditor5_definition_supporting_element_figure_two_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <figure data-one data-two>
elements:
- <figure data-one data-two>
ckeditor5_definition_supporting_element_dialog_two_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <dialog data-one data-two>
elements:
- <dialog data-one data-two>
ckeditor5_definition_supporting_element_dialog_one_attrib:
ckeditor5:
plugins: []
drupal:
label: TEST — <dialog data-one>
elements:
- <dialog data-one>

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Definition Supporting Tags Test
type: module
description: "Provides test plugins for CKEditor 5 to test finding plugin definitions that support specific tags."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,7 @@
name: CKEditor 5 Drupal Element Style Test
type: module
description: "Provides ability to run DrupalElementStyle CKEditor 5 plugin in multiple ways."
package: Testing
dependencies:
- drupal:ckeditor5
- drupal:media

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_drupalelementstyle_test\Hook;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\Hook\Attribute\Hook;
// cspell:ignore drupalelementstyle
/**
* Hook implementations for ckeditor5_drupalelementstyle_test.
*/
class Ckeditor5DrupalElementStyleTestHooks {
/**
* Implements hook_ckeditor5_plugin_info_alter().
*/
#[Hook('ckeditor5_plugin_info_alter')]
public function ckeditor5PluginInfoAlter(array &$plugin_definitions): void {
// Update `media_mediaAlign`.
assert($plugin_definitions['media_mediaAlign'] instanceof CKEditor5PluginDefinition);
$media_align_plugin_definition = $plugin_definitions['media_mediaAlign']->toArray();
$media_align_plugin_definition['ckeditor5']['config']['drupalMedia']['toolbar'] = [
0 => [
'name' => 'drupalMedia:align',
'title' => 'Test title',
'display' => 'splitButton',
'items' => array_values(array_filter($media_align_plugin_definition['ckeditor5']['config']['drupalMedia']['toolbar'], function (string $toolbar_item): bool {
return $toolbar_item !== '|';
})),
'defaultItem' => 'drupalElementStyle:align:breakText',
],
];
$plugin_definitions['media_mediaAlign'] = new CKEditor5PluginDefinition($media_align_plugin_definition);
}
}

View File

@ -0,0 +1,46 @@
# This plugin is for testing deprecation of CKEditor 5 icon names before version 45.
ckeditor5_icon_deprecation_test_plugin:
ckeditor5:
plugins: []
config:
drupalElementStyles:
align:
# This is a valid icon name.
- name: 'IconObjectCenter'
title: 'Icon object center'
icon: IconObjectCenter
modelElements: ['drupalMedia']
# The next four are deprecated icon names with specifically mapped to v45 icon names.
- name: 'objectBlockLeft'
title: 'Object block left'
icon: objectBlockLeft
modelElements: ['drupalMedia']
- name: 'objectBlockRight'
title: 'Object block right'
icon: objectBlockRight
modelElements: [ 'drupalMedia' ]
- name: 'objectLeft'
title: 'Object left'
icon: objectLeft
modelElements: [ 'drupalMedia' ]
- name: 'objectRight'
title: 'Object right'
icon: objectRight
modelElements: [ 'drupalMedia' ]
svg:
# Icon set as SVG XML.
- name: 'svg'
title: 'SVG'
icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>'
modelElements: [ 'drupalMedia' ]
threeVerticalDots:
# This is a deprecated icon name mapped with general rule 'exampleName' -> 'IconExampleName'.
- name: 'threeVerticalDots'
title: 'Three vertical dots'
icon: threeVerticalDots
modelElements: [ 'drupalMedia' ]
drupal:
label: Deprecated icons
elements:
- <drupal-media>

View File

@ -0,0 +1,7 @@
name: CKEditor icon deprecation test
type: module
description: "Provides test CKEditor5 plugin with deprecated Drupal element styles icon config"
package: Testing
version: VERSION
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Incompatible Filter Test
type: module
description: "Provides a filter incompatible with CKEditor 5"
package: Testing
dependencies:
- drupal:ckeditor5

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_incompatible_filter_test\Plugin\Filter;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\filter\Attribute\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\filter\Plugin\FilterInterface;
/**
* Provides a filter incompatible with CKEditor 5.
*/
#[Filter(
id: "filter_incompatible",
title: new TranslatableMarkup("A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5"),
type: FilterInterface::TYPE_MARKUP_LANGUAGE
)]
class FilterIsIncompatible extends FilterBase {
/**
* {@inheritdoc}
*/
public function process($text, $langcode) {
return new FilterProcessResult($text);
}
}

View File

@ -0,0 +1,25 @@
ckeditor5_manual_decorator_test_openInNewTab:
ckeditor5:
plugins: []
config:
link:
decorators:
openInNewTab:
mode: 'manual'
label: 'Open in a new tab'
attributes:
target: '_blank'
rel: 'noopener noreferrer'
classes: ['link-new-tab']
pinkColor:
mode: 'manual'
label: 'Pink color'
styles:
color: 'pink'
drupal:
label: Open in new tab
elements:
- <a target="_blank" rel="noopener noreferrer" class>
conditions:
plugins:
- ckeditor5_link

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Manual Decorator Test
type: module
description: "Provides configuration for CKEditor 5 link plugin manual decorator."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,26 @@
ckeditor5_plugin_conditions_test_plugins_condition:
ckeditor5:
plugins: {}
drupal:
label: TEST — Plugins Condition
toolbar_items:
fooBarConditions:
label: Foo Bar (Test Plugins Condition)
conditions:
plugins:
- ckeditor5_heading
- ckeditor5_table
elements:
- <foo>
ckeditor5_plugin_conditions_test_plugin_allow_all_classes_on_kbd:
ckeditor5:
plugins: {}
drupal:
label: TEST — Allow all classes on kbd
toolbar_items:
kbdAllClasses:
label: All classes on kbd (Test Plugins Condition)
elements:
- <kbd>
- <kbd class>

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Plugin Conditions Test
type: module
description: "Provides test plugins for CKEditor 5 to test plugin conditions."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,11 @@
ckeditor5_plugin_elements_subset_sneakySuperset:
ckeditor5:
plugins: []
drupal:
label: Sneaky Superset
class: Drupal\ckeditor5_plugin_elements_subset\Plugin\CKEditor5Plugin\SneakySuperset
elements:
- <foo>
- <bar>
- <bar baz>
- <$any-html5-element class>

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Plugin Elements Subset Test
type: module
description: "Provides test plugin to test CKEditor5PluginElementsSubsetInterface"
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,10 @@
ckeditor5.plugin.ckeditor5_plugin_elements_subset_sneakySuperset:
type: mapping
label: Sneaky Superset
mapping:
configured_subset:
type: sequence
label: 'Allowed Tags'
sequence:
type: ckeditor5.element
label: 'Allowed Tag'

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_plugin_elements_subset\Plugin\CKEditor5Plugin;
use Drupal\ckeditor5\Plugin\CKEditor5PluginConfigurableTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault;
use Drupal\ckeditor5\Plugin\CKEditor5PluginElementsSubsetInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a plugin for testing CKEditor.
*/
class SneakySuperset extends CKEditor5PluginDefault implements CKEditor5PluginElementsSubsetInterface {
use CKEditor5PluginConfigurableTrait;
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
return [];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'configured_subset' => [],
];
}
/**
* {@inheritdoc}
*/
public function getElementsSubset(): array {
return $this->configuration['configured_subset'];
}
}

View File

@ -0,0 +1,33 @@
# cspell:ignore everytextcontainer justheading
ckeditor5_plugin_elements_test_headingCombo:
ckeditor5:
plugins: []
drupal:
label: TEST — block quote combo
elements:
- <h1 data-justheading>
- <$text-container data-everytextcontainer>
ckeditor5_plugin_elements_test_headingsWithOtherAttributes:
ckeditor5:
plugins: []
drupal:
label: TEST — headings with other attributes
elements:
- <h1 data-just-h1>
- <h2 class="additional-allowed-class">
- <h3 data-just-h3 data-just-h3-limited="i-am-the-only-allowed-value">
- <h5 data-just-h5-limited="first-allowed-value second-allowed-value">
ckeditor5_plugin_elements_test_headingsUseClassAnyValue:
ckeditor5:
plugins: []
drupal:
label: TEST — headings with any class
elements:
- <h1 class>
- <h2 class>
- <h3 class>
- <h4 class>
- <h5 class>
- <h6 class>

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Plugin Elements Test
type: module
description: "Provides test plugins for CKEditor 5 to test allowed elements parsing."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,6 @@
name: CKEditor 5 read-only mode test
type: module
description: "Provides code for testing disabled CKEditor 5 editors."
package: Testing
dependencies:
- ckeditor5:ckeditor5

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_read_only_mode\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for ckeditor5_read_only_mode.
*/
class Ckeditor5ReadOnlyModeHooks {
/**
* Implements hook_form_FORM_ID_alter().
*/
#[Hook('form_node_page_form_alter')]
public function formNodePageFormAlter(array &$form, FormStateInterface $form_state, string $form_id) : void {
$form['body']['#disabled'] = \Drupal::state()->get('ckeditor5_read_only_mode_body_enabled', FALSE);
$form['field_second_ckeditor5_field']['#disabled'] = \Drupal::state()->get('ckeditor5_read_only_mode_second_ckeditor5_field_enabled', FALSE);
}
}

View File

@ -0,0 +1,29 @@
# cspell:ignore layercake
ckeditor5_test_layercake:
ckeditor5:
plugins: []
config:
drupalElementStyles:
layercake:
- name: 'layerCakeSide'
title: 'Media aligned to side'
icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>'
attributeName: 'class'
attributeValue: 'layercake-side'
modelElements: ['drupalMedia']
drupalMediaStyles:
toolbar:
- drupalElementStyle:layerCakeSide
drupal:
label: TEST — Layercake
library: ckeditor5_test/layercake
toolbar_items:
simpleBox:
label: Simple Box
twoCol:
label: Two Col layout
elements:
- <h1 class>
- <div class>
- <section class>
- <drupal-media class="layercake-side">

View File

@ -0,0 +1,7 @@
name: CKEditor 5 Test
type: module
description: "Provides test layout and component plugins for CKEditor 5."
package: Testing
dependencies:
- drupal:editor
- ckeditor5:ckeditor5

View File

@ -0,0 +1,10 @@
# cspell:ignore layercake
layercake:
version: VERSION
# In real-world (non-test) scenarios, this would load the CKEditor 5 plugin's built JS.
js: {}
css:
theme:
css/layout.css: {}
dependencies:
- core/ckeditor5

View File

@ -0,0 +1,13 @@
ckeditor5_test.off_canvas:
path: '/ckeditor5_test/off_canvas'
defaults:
_controller: '\Drupal\ckeditor5_test\Controller\CKEditor5OffCanvasTestController::testOffCanvas'
requirements:
_access: 'TRUE'
ckeditor5_test.dialog:
path: '/ckeditor5_test/dialog'
defaults:
_controller: '\Drupal\ckeditor5_test\Controller\CKEditor5DialogTestController::testDialog'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,14 @@
.layout--two-col {
display: grid;
grid-template-columns: 1fr 1fr;
}
.simple-box {
padding: 0.5rem;
background: #ccc;
}
.simple-box-title,
.simple-box-description {
background: #fff;
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_test\Controller;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
/**
* Provides controller for testing CKEditor in off-canvas dialogs.
*/
class CKEditor5DialogTestController {
/**
* Returns a link that can open a node add form in an modal dialog.
*
* @return array
* A render array.
*/
public function testDialog() {
$build['link'] = [
'#type' => 'link',
'#title' => 'Add Node',
'#url' => Url::fromRoute('node.add', ['node_type' => 'page']),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-options' => Json::encode([
'width' => 700,
'modal' => TRUE,
'autoResize' => TRUE,
]),
],
];
$build['#attached']['library'][] = 'core/drupal.dialog.ajax';
return $build;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_test\Controller;
use Drupal\Core\Url;
/**
* Provides controller for testing CKEditor in off-canvas dialogs.
*/
class CKEditor5OffCanvasTestController {
/**
* Returns a link that can open a node add form in an off-canvas dialog.
*
* @return array
* A render array.
*/
public function testOffCanvas() {
$build['link'] = [
'#type' => 'link',
'#title' => 'Add Node',
'#url' => Url::fromRoute('node.add', ['node_type' => 'page']),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
];
$build['#attached']['library'][] = 'core/drupal.dialog.off_canvas';
return $build;
}
}

View File

@ -0,0 +1,6 @@
name: CKEditor 5 Module Allowed Image
type: module
description: Alters the allowed image types.
package: Testing
dependencies:
- drupal:ckeditor5

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor5_test_module_allowed_image\Hook;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for ckeditor5_test_module_allowed_image.
*/
class Ckeditor5TestModuleAllowedImageHooks {
/**
* Implements hook_ckeditor5_plugin_info_alter().
*/
#[Hook('ckeditor5_plugin_info_alter')]
public function ckeditor5PluginInfoAlter(array &$plugin_definitions) : void {
// Add a custom file type to the image upload plugin. Note that 'svg+xml'
// below should be an IANA image media type Name, with the "image/" prefix
// omitted. In other words: a subtype of type image.
// @see https://www.iana.org/assignments/media-types/media-types.xhtml#image
// @see https://ckeditor.com/docs/ckeditor5/latest/api/module_image_imageconfig-ImageUploadConfig.html#member-types
$image_upload_plugin_definition = $plugin_definitions['ckeditor5_imageUpload']->toArray();
$image_upload_plugin_definition['ckeditor5']['config']['image']['upload']['types'][] = 'svg+xml';
$plugin_definitions['ckeditor5_imageUpload'] = new CKEditor5PluginDefinition($image_upload_plugin_definition);
}
}

View File

@ -0,0 +1,6 @@
name: CKEditor Test
type: module
description: "Provides test a test editor that with the ID ckeditor."
package: Testing
dependencies:
- drupal:editor_test

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Drupal\ckeditor_test\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for ckeditor_test.
*/
class CkeditorTestHooks {
/**
* Implements hook_editor_info_alter().
*/
#[Hook('editor_info_alter')]
public function editorInfoAlter(array &$editors): void {
// Drupal 9 used to have an editor called ckeditor. Copy the Unicorn editor
// to it to be able to test upgrading to CKEditor 5.
$editors['ckeditor'] = $editors['unicorn'];
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Test the ckeditor5-stylesheets theme config property.
*
* @group ckeditor5
*/
class AddedStylesheetsTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The editor user.
*
* @var \Drupal\editor\Entity\Editor
*/
protected Editor $editor;
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $adminUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$filtered_html_format = FilterFormat::create([
'format' => 'llama',
'name' => 'Llama',
'filters' => [],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$filtered_html_format->save();
$this->editor = Editor::create([
'format' => 'llama',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [],
],
],
]);
$this->editor->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair($this->editor, $filtered_html_format))
));
// Create node type.
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
$this->adminUser = $this->drupalCreateUser([
'create article content',
'use text format llama',
'administer themes',
'view the administration theme',
'administer filters',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Test the ckeditor5-stylesheets theme config.
*/
public function testCkeditorStylesheets(): void {
$assert_session = $this->assertSession();
/** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */
$theme_installer = \Drupal::service('theme_installer');
$theme_installer->install(['test_ckeditor_stylesheets_relative', 'claro']);
$this->config('system.theme')->set('admin', 'claro')->save();
$this->drupalGet('node/add/article');
$assert_session->responseNotContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
// Confirm that the missing ckeditor5-stylesheets configuration can be
// bypassed.
$this->drupalGet('admin/config/content/formats/manage/llama');
$assert_session->pageTextNotContains('ckeditor_stylesheets configured without a corresponding ckeditor5-stylesheets configuration.');
// Install a theme with ckeditor5-stylesheets configured. Do this manually
// to confirm `library_info` cache tags are invalidated.
$this->drupalGet('admin/appearance');
$this->clickLink('Set Test relative CKEditor stylesheets as default theme');
// Confirm the stylesheet added via `ckeditor5-stylesheets` is present.
$this->drupalGet('node/add/article');
$assert_session->responseContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
// Change the default theme to Stark, and confirm the stylesheet added via
// `ckeditor5-stylesheets` is no longer present.
$this->drupalGet('admin/appearance');
$this->clickLink('Set Stark as default theme');
$this->drupalGet('node/add/article');
$assert_session->responseNotContains('test_ckeditor_stylesheets_relative/css/yokotsoko.css');
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for ckeditor5.
*
* @group ckeditor5
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\File\FileExists;
/**
* Test image upload access.
*
* @group ckeditor5
* @internal
*/
class ImageUploadAccessTest extends ImageUploadTest {
/**
* Test access to the CKEditor 5 image upload controller.
*/
public function testCkeditor5ImageUploadRoute(): void {
$this->createBasicFormat();
$url = $this->getUploadUrl();
$test_image = file_get_contents(current($this->getTestFiles('image'))->uri);
// With no text editor, expect a 404.
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(404, $response->getStatusCode());
$editor = $this->createEditorWithUpload(['status' => FALSE]);
// Ensure that images cannot be uploaded when image upload is disabled.
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(403, $response->getStatusCode());
$editor->setImageUploadSettings([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
])->save();
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(201, $response->getStatusCode());
// Ensure lock failures are reported correctly.
$d = 'public://inline-images/test.jpg';
$f = $this->container->get('file_system')->getDestinationFilename($d, FileExists::Rename);
$this->container->get('lock')
->acquire('file:ckeditor5:' . Crypt::hashBase64($f));
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(503, $response->getStatusCode());
$this->assertStringContainsString('File &quot;public://inline-images/test_0.jpg&quot; is already locked for writing.', (string) $response->getBody());
// Ensure that users without permissions to the text format cannot upload
// images.
$this->drupalLogout();
$response = $this->uploadRequest($url, $test_image, 'test.jpg');
$this->assertSame(403, $response->getStatusCode());
}
}

View File

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\ckeditor5\Traits\SynchronizeCsrfTokenSeedTrait;
use Drupal\Tests\jsonapi\Functional\JsonApiRequestTestTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Test image upload.
*
* @group ckeditor5
* @internal
*/
class ImageUploadTest extends BrowserTestBase {
use JsonApiRequestTestTrait;
use TestFileCreationTrait;
use SynchronizeCsrfTokenSeedTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'editor',
'filter',
'ckeditor5',
];
/**
* A user without any particular permissions to be used in testing.
*
* @var \Drupal\user\Entity\User
*/
protected $user;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->user = $this->drupalCreateUser();
$this->drupalLogin($this->user);
}
/**
* Tests using the file upload route with a disallowed extension.
*/
public function testUploadFileExtension(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
]);
$url = $this->getUploadUrl();
$image_file = file_get_contents(current($this->getTestFiles('image'))->uri);
$non_image_file = file_get_contents(current($this->getTestFiles('php'))->uri);
$response = $this->uploadRequest($url, $non_image_file, 'test.php');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, $image_file, 'test.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Tests using the file upload route with a file size larger than allowed.
*/
public function testFileUploadLargerFileSize(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => 30000,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
]);
$url = $this->getUploadUrl();
$images = $this->getTestFiles('image');
$large_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size > 30000;
});
$small_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size < 30000;
});
$response = $this->uploadRequest($url, file_get_contents($large_image->uri), 'large.jpg');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, file_get_contents($small_image->uri), 'small.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Test that lock is removed after a failed validation.
*
* @see https://www.drupal.org/project/drupal/issues/3184974
*/
public function testLockAfterFailedValidation(): void {
$this->createBasicFormat();
$this->createEditorWithUpload([
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => 30000,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
]);
$url = $this->getUploadUrl();
$images = $this->getTestFiles('image');
$large_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size > 30000;
});
$small_image = $this->getTestImageByStat($images, 'size', function ($size) {
return $size < 30000;
});
$response = $this->uploadRequest($url, file_get_contents($large_image->uri), 'same.jpg');
$this->assertSame(422, $response->getStatusCode());
$response = $this->uploadRequest($url, file_get_contents($small_image->uri), 'same.jpg');
$this->assertSame(201, $response->getStatusCode());
}
/**
* Make upload request to a controller.
*
* @param \Drupal\Core\Url $url
* The URL for the request.
* @param string $file_contents
* File contents.
* @param string $file_name
* Name of the file.
*
* @return \Psr\Http\Message\ResponseInterface
* The response.
*/
protected function uploadRequest(Url $url, string $file_contents, string $file_name): ResponseInterface {
$request_options[RequestOptions::HEADERS] = [
'Accept' => 'application/json',
];
$request_options[RequestOptions::MULTIPART] = [
[
'name' => 'upload',
'filename' => $file_name,
'contents' => $file_contents,
],
];
return $this->request('POST', $url, $request_options);
}
/**
* Provides the image upload URL.
*
* @return \Drupal\Core\Url
* The upload image URL for the basic_html format.
*/
protected function getUploadUrl() {
$token = $this->container->get('csrf_token')->get('ckeditor5/upload-image/basic_html');
return Url::fromRoute('ckeditor5.upload_image', ['editor' => 'basic_html'], ['query' => ['token' => $token]]);
}
/**
* Create a basic_html text format for the editor to reference.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function createBasicFormat(): void {
$basic_html_format = FilterFormat::create([
'format' => 'basic_html',
'name' => 'Basic HTML',
'weight' => 1,
'filters' => [
'filter_html_escape' => ['status' => 1],
],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$basic_html_format->save();
}
/**
* Create an editor entity with image_upload config.
*
* @param array $upload_config
* The editor image_upload config.
*
* @return \Drupal\Core\Entity\EntityBase|\Drupal\Core\Entity\EntityInterface
* The text editor entity.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function createEditorWithUpload(array $upload_config) {
$editor = Editor::create([
'editor' => 'ckeditor5',
'format' => 'basic_html',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
],
],
'plugins' => [
'ckeditor5_imageResize' => [
'allow_resize' => FALSE,
],
],
],
'image_upload' => $upload_config,
]);
$editor->save();
return $editor;
}
/**
* Return the first image matching $condition.
*
* @param array $images
* Images created with getTestFiles().
* @param string $stat
* A key in the array returned from stat().
* @param callable $condition
* A function to compare a value of the image file.
*
* @return object|bool
* Objects with 'uri', 'filename', and 'name' properties.
*/
protected function getTestImageByStat(array $images, string $stat, callable $condition) {
return current(array_filter($images, function ($image) use ($condition, $stat) {
$stats = stat($image->uri);
return $condition($stats[$stat]);
}));
}
}

View File

@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Functional;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\language\Entity\ContentLanguageSettings;
use Drupal\media\Entity\Media;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\ckeditor5\Traits\SynchronizeCsrfTokenSeedTrait;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Tests the media entity metadata API.
*
* @group ckeditor5
* @internal
*/
class MediaEntityMetadataApiTest extends BrowserTestBase {
use TestFileCreationTrait;
use MediaTypeCreationTrait;
use SynchronizeCsrfTokenSeedTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'filter',
'editor',
'ckeditor5',
'media',
];
/**
* The sample image media entity to use for testing.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaImage;
/**
* The sample file media entity to use for testing.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaFile;
/**
* The editor instance to use for testing.
*
* @var \Drupal\editor\Entity\Editor
*/
protected $editor;
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $adminUser;
/**
* @var \Drupal\Component\Uuid\UuidInterface
*/
protected $uuidService;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->uuidService = $this->container->get('uuid');
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.view_mode_2',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2',
])->save();
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
'filters' => [
'filter_html' => [
'id' => 'filter_html',
'status' => TRUE,
'weight' => -10,
'settings' => [
'allowed_html' => "<p> <br> <drupal-media data-entity-type data-entity-uuid data-view-mode alt>",
'filter_html_help' => TRUE,
'filter_html_nofollow' => TRUE,
],
],
'media_embed' => [
'id' => 'media_embed',
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'view_mode_2' => 'view_mode_2',
],
'allowed_media_types' => [],
],
],
],
'roles' => [RoleInterface::AUTHENTICATED_ID],
]);
$filtered_html_format->save();
$this->editor = Editor::create([
'format' => 'filtered_html',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [],
],
'plugins' => [
'media_media' => [
'allow_view_mode_override' => TRUE,
],
],
],
]);
$this->editor->save();
$filtered_html_format->setFilterConfig('media_embed', [
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_media_types' => [],
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'view_mode_2' => 'view_mode_2',
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair($this->editor, $filtered_html_format))
));
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->mediaImage = Media::create([
'bundle' => 'image',
'name' => 'Screaming hairy armadillo',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->mediaImage->save();
$this->createMediaType('file', ['id' => 'file']);
File::create([
'uri' => $this->getTestFiles('text')[0]->uri,
])->save();
$this->mediaFile = Media::create([
'bundle' => 'file',
'name' => 'Information about screaming hairy armadillo',
'field_media_file' => [
[
'target_id' => 2,
],
],
]);
$this->mediaFile->save();
$this->adminUser = $this->drupalCreateUser([
'use text format filtered_html',
]);
$this->drupalLogin($this->adminUser);
}
/**
* Tests the media entity metadata API.
*/
public function testApi(): void {
$path = '/ckeditor5/filtered_html/media-entity-metadata';
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
$uuid = $this->mediaImage->uuid();
$this->drupalGet($path, ['query' => ['token' => $token]]);
$this->assertSession()->statusCodeEquals(400);
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(["type" => "image", 'imageSourceMetadata' => ['alt' => 'default alt']]), $this->getSession()->getPage()->getContent());
$this->mediaImage->set('field_media_image', [
'target_id' => 1,
'alt' => '',
'title' => 'default title',
])->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => '']]), $this->getSession()->getPage()->getContent());
// Test that setting the media image field to not display alt field also
// omits it from the API (which will in turn instruct the CKE5 plugin to not
// show it).
FieldConfig::loadByName('media', 'image', 'field_media_image')
->setSetting('alt_field', FALSE)
->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image']), $this->getSession()->getPage()->getContent());
$this->drupalGet($path, ['query' => ['uuid' => $this->mediaFile->uuid(), 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'file']), $this->getSession()->getPage()->getContent());
// Ensure that unpublished media returns 403.
$this->mediaImage->setUnpublished()->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
// Ensure that valid, but non-existing UUID returns 404.
$this->drupalGet($path, ['query' => ['uuid' => $this->uuidService->generate(), 'token' => $token]]);
$this->assertSession()->statusCodeEquals(404);
// Ensure that invalid UUID returns 400.
$this->drupalGet($path, ['query' => ['uuid' => '🦙', 'token' => $token]]);
$this->assertSession()->statusCodeEquals(400);
// Ensure that users that don't have access to the filter format receive
// either 404 or 403.
$this->drupalLogout();
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
$this->drupalGet($path, ['token' => $token]);
$this->assertSession()->statusCodeEquals(400);
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
$this->mediaImage->setPublished()->save();
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests the media entity metadata API with translations.
*/
public function testApiTranslation(): void {
$this->container->get('module_installer')->install(['language', 'content_translation']);
$this->resetAll();
ConfigurableLanguage::createFromLangcode('fi')->save();
$this->container->get('config.factory')->getEditable('language.negotiation')
->set('url.source', 'path_prefix')
->set('url.prefixes.fi', 'fi')
->save();
$this->rebuildContainer();
ContentLanguageSettings::loadByEntityTypeBundle('media', 'image')
->setDefaultLangcode('en')
->setLanguageAlterable(TRUE)
->save();
$media_fi = Media::load($this->mediaImage->id())->addTranslation('fi');
$media_fi->field_media_image->setValue([
[
'target_id' => '1',
// cSpell:disable-next-line
'alt' => 'oletus alt-teksti kuvalle',
],
]);
$media_fi->save();
$uuid = $this->mediaImage->uuid();
$path = '/ckeditor5/filtered_html/media-entity-metadata';
$token = $this->container->get('csrf_token')->get(ltrim($path, '/'));
// Ensure that translation is returned when language is specified.
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token], 'language' => $media_fi->language()]);
$this->assertSession()->statusCodeEquals(200);
// cSpell:disable-next-line
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => 'oletus alt-teksti kuvalle']]), $this->getSession()->getPage()->getContent());
// Ensure that default translation is returned when no language is
// specified.
$this->drupalGet($path, ['query' => ['uuid' => $uuid, 'token' => $token]]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame(json_encode(['type' => 'image', 'imageSourceMetadata' => ['alt' => 'default alt']]), $this->getSession()->getPage()->getContent());
}
}

View File

@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore sourceediting xmlhttprequest
/**
* Tests for CKEditor 5 in the admin UI.
*
* @group ckeditor5
* @internal
*/
class AdminUiTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'media_library',
'editor_test',
'ckeditor5_incompatible_filter_test',
];
/**
* Confirm settings only trigger AJAX when select value is CKEditor 5.
*/
public function testSettingsOnlyFireAjaxWithCkeditor5(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat();
$this->addNewTextFormat('unicorn');
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Enable media embed to trigger an AJAX rebuild.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$this->assertNoAjaxRequestTriggered();
$page->checkField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(1);
// Perform the same steps as above with CKEditor, and confirm AJAX callbacks
// are not triggered on settings changes.
$this->drupalGet('admin/config/content/formats/manage/unicorn');
// Enable media embed to confirm a format not using CKEditor 5 will not
// trigger an AJAX rebuild.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$this->assertNoAjaxRequestTriggered();
// Confirm that AJAX updates happen when attempting to switch to CKEditor 5,
// even if prevented from doing so by validation.
$this->drupalGet('admin/config/content/formats/add');
$this->assertFalse($assert_session->elementExists('css', '#edit-name-machine-name-suffix')->isVisible());
$name_field = $page->findField('name');
$name_field->setValue('trigger validator');
$this->assertTrue($assert_session->elementExists('css', '#edit-name-machine-name-suffix')->isVisible());
// Enable a filter that is incompatible with CKEditor 5, so validation is
// triggered when attempting to switch.
$incompatible_filter_name = 'filters[filter_incompatible][status]';
$this->assertTrue($page->hasUncheckedField($incompatible_filter_name));
$page->checkField($incompatible_filter_name);
$this->assertNoAjaxRequestTriggered();
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertExpectedAjaxRequest(1);
$filter_warning = 'CKEditor 5 only works with HTML-based text formats. The "A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5" (filter_incompatible) filter implies this text format is not HTML anymore.';
// The presence of this validation error message confirms the AJAX callback
// was invoked.
$assert_session->pageTextContains($filter_warning);
// Disable the incompatible filter. This should trigger another AJAX rebuild
// which will include the removal of the validation error as the issue has
// been corrected.
$this->assertTrue($page->hasCheckedField($incompatible_filter_name));
$page->uncheckField($incompatible_filter_name);
$assert_session->assertExpectedAjaxRequest(2);
$assert_session->pageTextNotContains($filter_warning);
}
/**
* Asserts that no (new) AJAX requests were triggered.
*
* @param int $expected_cumulative_ajax_request_count
* The number of expected observed XHR requests since the page was loaded.
*/
protected function assertNoAjaxRequestTriggered(int $expected_cumulative_ajax_request_count = 0): void {
// In case of no requests triggered at all yet.
if ($expected_cumulative_ajax_request_count === 0) {
$result = $this->getSession()->evaluateScript(<<<JS
(function() {
return window.drupalCumulativeXhrCount;
}())
JS);
$this->assertSame(0, $result);
}
else {
// In case of the non-first AJAX request, ensure that no AJAX requests are
// in progress.
try {
$this->assertSession()->assertWaitOnAjaxRequest(500);
}
catch (\RuntimeException $e) {
throw new \LogicException(sprintf('This call to %s claims there no AJAX request was triggered, but this is wrong: %s.', __METHOD__, $e->getMessage()));
}
catch (\LogicException $e) {
// This is the intent: ::assertWaitOnAjaxRequest() should detect an
// "incorrect" call, because this assertion is asserting *no* AJAX
// requests have been triggered.
assert(str_contains($e->getMessage(), 'Unnecessary'));
$result = $this->getSession()->evaluateScript(<<<JS
(function() {
return window.drupalCumulativeXhrCount;
}())
JS);
$this->assertSame($expected_cumulative_ajax_request_count, $result);
}
}
// Now that there definitely is no more AJAX request in progress, count the
// number of actual XHR requests, ensure they match.
$javascript = <<<JS
(function(){
return window.performance
.getEntries()
.filter(entry => entry.initiatorType === 'xmlhttprequest')
.length
})()
JS;
$this->assertSame($expected_cumulative_ajax_request_count, $this->getSession()->evaluateScript($javascript));
}
/**
* CKEditor 5's filter UI modifications should not break it for other editors.
*/
public function testUnavailableFiltersHiddenWhenSwitching(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session, 'unicorn');
$assert_session->pageTextNotContains('Filter settings');
// Switching to CKEditor 5 should keep the filter settings hidden.
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->pageTextNotContains('Filter settings');
}
/**
* Test that filter settings are only visible when the filter is enabled.
*/
public function testFilterCheckboxesToggleSettings(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$media_tab = $page->find('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertFalse($media_tab->isVisible(), 'Media filter settings should not be present because media filter is not enabled');
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
$media_tab = $assert_session->waitForElementVisible('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertTrue($media_tab->isVisible(), 'Media settings should appear when media filter enabled');
$page->uncheckField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
$media_tab = $page->find('css', '[href^="#edit-filters-media-embed-settings"]');
$this->assertFalse($media_tab->isVisible(), 'Media settings should be removed when media filter disabled');
}
/**
* Tests that image upload settings (stored out of band) are validated too.
*/
public function testImageUploadSettingsAreValidated(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat();
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Add the image plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(1);
// Open the vertical tab with its settings.
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-image"]')->click();
$this->assertTrue($assert_session->waitForText('Enable image uploads'));
// Check the "Enable image uploads" checkbox.
$assert_session->checkboxNotChecked('editor[settings][plugins][ckeditor5_image][status]');
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertExpectedAjaxRequest(2);
// Enter a nonsensical maximum file size.
$page->fillField('editor[settings][plugins][ckeditor5_image][max_size]', 'foobar');
$this->assertNoRealtimeValidationErrors();
// Enable another toolbar item to trigger validation.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(3);
// The expected validation error must be present.
$assert_session->elementExists('css', '[role=alert]:contains("This value must be a number of bytes, optionally with a unit such as "MB" or "megabytes".")');
// Enter no maximum file size because it is optional, this should result in
// no validation error and it being set to `null`.
$page->findField('editor[settings][plugins][ckeditor5_image][max_size]')->setValue('');
// Remove a toolbar item to trigger validation.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowUp');
$assert_session->assertExpectedAjaxRequest(4);
// No more validation errors, let's save.
$this->assertNoRealtimeValidationErrors();
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated');
}
/**
* Ensure CKEditor 5 admin UI's real-time validation errors do not accumulate.
*/
public function testMessagesDoNotAccumulate(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat();
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
// Add the source editing plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$find_validation_error_messages = function () use ($page): array {
return $page->findAll('css', '[role=alert]:contains("The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>).")');
};
// No validation errors when we start.
$this->assertCount(0, $find_validation_error_messages());
// Configure Source Editing to allow editing `<strong>` to trigger
// validation error.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->waitForText('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<strong>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(1, $find_validation_error_messages());
// Revert Source Editing it: validation messages should be gone.
$source_edit_tags_field->setValue('');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(0, $find_validation_error_messages());
// Add `<strong>` again: validation messages should be back.
$source_edit_tags_field->setValue('<strong>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertCount(1, $find_validation_error_messages());
}
/**
* Tests the plugin settings form section.
*/
public function testPluginSettingsFormSection(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The default toolbar only enables the configurable heading plugin and the
// non-configurable bold and italic plugins.
$assert_session->fieldValueEquals('editor[settings][toolbar][items]', '["heading","bold","italic"]');
// The heading plugin config form should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-heading"]');
// Remove the heading plugin from the toolbar.
$this->triggerKeyUp('.ckeditor5-toolbar-item-heading', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// The heading plugin config form should no longer be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-heading"]');
// The plugin settings wrapper should still be present, but empty.
$assert_session->elementExists('css', '#plugin-settings-wrapper');
$assert_session->elementNotContains('css', '#plugin-settings-wrapper', '<div');
// Enable the source plugin.
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The source plugin config form should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting"]');
// The filter-dependent configurable plugin should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-media-media"]');
// Enable the filter that the configurable plugin depends on.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$page->checkField('filters[media_embed][status]');
$assert_session->assertWaitOnAjaxRequest();
// The filter-dependent configurable plugin should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-media-media"]');
}
/**
* Tests the language config form.
*/
public function testLanguageConfigForm(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The language plugin config form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-textPartLanguage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-textPartLanguage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The CKEditor 5 module should warn that `<span>` cannot be created.
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="warning"]:contains("The Language plugin needs another plugin to create <span>, for it to be able to create the following attributes: <span lang dir>. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// Make `<span>` creatable.
$this->assertNotEmpty($assert_session->elementExists('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<span>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Dispatching an `input` event does not work in WebDriver. Enabling another
// toolbar item which has no associated HTML elements forces it.
$this->triggerKeyUp('.ckeditor5-toolbar-item-undo', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Confirm there are no longer any warnings.
$assert_session->waitForElementRemoved('css', '[data-drupal-messages] [role="alert"]');
// The language plugin config form should now be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
// It must also be possible to remove the language plugin again.
$this->triggerKeyUp('.ckeditor5-toolbar-item-textPartLanguage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// The language plugin config form should not be present anymore.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-language"]');
}
}

View File

@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Symfony\Component\Yaml\Yaml;
// cspell:ignore esque imageUpload sourceediting Editing's
/**
* Tests for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5AllowedTagsTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'editor_test',
'ckeditor5',
'media',
'media_library',
'ckeditor5_incompatible_filter_test',
];
/**
* The default CKEditor 5 allowed elements.
*
* @var string
*/
protected $allowedElements = '<br> <p> <h2> <h3> <h4> <h5> <h6> <strong> <em>';
/**
* The default allowed elements for filter_html's "allowed_html" setting.
*
* @var string
*
* @see \Drupal\filter\Plugin\Filter\FilterHtml
*/
protected $defaultElementsWhenUpdatingNotCkeditor5 = "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id>";
/**
* The expected allowed elements after updating to CKEditor 5.
*
* @var string
*/
protected $defaultElementsAfterUpdatingToCkeditor5 = '<br> <p> <h2 id="jump-*"> <h3 id> <h4 id> <h5 id> <h6 id> <cite> <dl> <dt> <dd> <a hreflang href> <blockquote cite> <ul type> <ol type="1 A I" reversed start> <strong> <em> <code> <li>';
/**
* Test enabling CKEditor 5 in a way that triggers validation.
*/
public function testEnablingToVersion5Validation(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$incompatible_filter_name = 'filters[filter_incompatible][status]';
$filter_warning = 'CKEditor 5 only works with HTML-based text formats. The "A TYPE_MARKUP_LANGUAGE filter incompatible with CKEditor 5" (filter_incompatible) filter implies this text format is not HTML anymore.';
$this->createNewTextFormat($page, $assert_session, 'unicorn');
$page->checkField('filters[filter_html][status]');
$page->checkField($incompatible_filter_name);
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertExpectedAjaxRequest(2);
$assert_session->pageTextContains($filter_warning);
// Disable the incompatible filter.
$page->uncheckField($incompatible_filter_name);
// Confirm there are no longer any warnings.
$assert_session->waitForElementRemoved('css', '[data-drupal-messages] [role="alert"]');
// Confirm the text format can be saved.
$this->saveNewTextFormat($page, $assert_session);
}
/**
* Tests that when image uploads were enabled, they remain enabled.
*/
public function testImageUploadsRemainEnabled(): void {
FilterFormat::create([
'format' => 'editor_with_image_uploads',
'name' => 'Text Editor with image uploads enabled',
])->save();
Editor::create([
'format' => 'editor_with_image_uploads',
'editor' => 'unicorn',
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
])->save();
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Assert that image uploads are enabled initially.
$this->drupalGet('admin/config/content/formats/manage/editor_with_image_uploads');
$this->assertTrue($page->hasCheckedField('Enable image uploads'));
// Switch the text format to CKEditor 5.
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
// Enable the image toolbar item. This does NOT enable image uploads: it
// triggers the image upload settings form to become visible, to allow the
// image upload status to be checked.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Assert that image uploads are still enabled.
$this->assertTrue($page->hasCheckedField('Enable image uploads'));
}
/**
* Confirm that switching to CKEditor 5 from another editor updates tags.
*/
public function testSwitchToVersion5(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session, 'unicorn');
// Enable the HTML filter.
$this->assertTrue($page->hasUncheckedField('filters[filter_html][status]'));
$page->checkField('filters[filter_html][status]');
// Confirm the allowed HTML tags are the defaults initially.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsWhenUpdatingNotCkeditor5);
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format unicorn');
// Return to the config form to confirm that switching text editors on
// existing formats will properly switch allowed tags.
$this->drupalGet('admin/config/content/formats/manage/unicorn');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsWhenUpdatingNotCkeditor5);
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->pageTextContains('The <br>, <p> tags were added because they are required by CKEditor 5');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->defaultElementsAfterUpdatingToCkeditor5);
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format unicorn has been updated');
}
/**
* Tests that the img tag is added after enabling image uploads.
*/
public function testImgAddedViaUploadPlugin(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Allowed tags are currently the default, with no <img>.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
// The image upload settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageupload"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The image upload settings form should now be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-active .ckeditor5-toolbar-item-drupalInsertImage'));
// The image insert plugin is enabled and inserting <img> is allowed.
$this->assertEquals($this->allowedElements . ' <img src alt height width>', $allowed_html_field->getValue());
$page->clickLink('Image');
$assert_session->waitForText('Enable image uploads');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_image][status]'));
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
// Enabling image uploads adds <img> with several attributes to allowed
// tags.
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type>', $allowed_html_field->getValue());
// Also enabling the caption filter will add the data-caption attribute to
// <img>.
$this->assertTrue($page->hasUncheckedField('filters[filter_caption][status]'));
$page->checkField('filters[filter_caption][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type data-caption>', $allowed_html_field->getValue());
// Also enabling the alignment filter will add the data-align attribute to
// <img>.
$this->assertTrue($page->hasUncheckedField('filters[filter_align][status]'));
$page->checkField('filters[filter_align][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertEquals($this->allowedElements . ' <img src alt height width data-entity-uuid data-entity-type data-caption data-align>', $allowed_html_field->getValue());
// Disable image upload.
$page->clickLink('Image');
$assert_session->waitForText('Enable image uploads');
$this->assertTrue($page->hasCheckedField('editor[settings][plugins][ckeditor5_image][status]'));
$page->uncheckField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
// The image insert is still allowed when image uploads are disabled.
$this->assertEquals($this->allowedElements . ' <img src alt height width data-caption data-align>', $allowed_html_field->getValue());
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
// Confirm <img> is no longer an allowed tag, once image insert is disabled.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
}
/**
* Test filter_html allowed tags.
*/
public function testAllowedTags(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// Confirm the "allowed tags" field is read only, and the value
// matches the tags required by CKEditor.
// Allowed HTML field is readonly and its wrapper has a form-disabled class.
$this->assertNotEmpty($assert_session->waitForElement('css', '.js-form-item-filters-filter-html-settings-allowed-html.form-disabled'));
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
$this->assertSame($this->allowedElements, $allowed_html_field->getValue());
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format ckeditor5');
$assert_session->pageTextContains('Text formats and editors');
// Confirm the filter config was updated with the correct allowed tags.
$this->assertSame($this->allowedElements, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// Add the block quote plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-blockQuote'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-blockQuote', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$allowed_with_blockquote = $this->allowedElements . ' <blockquote>';
$assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_blockquote);
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated.');
// Flush caches so the updated config can be checked.
drupal_flush_all_caches();
// Confirm that the tags required by the newly-added plugins were correctly
// saved.
$this->assertSame($allowed_with_blockquote, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// And for good measure, confirm the correct tags are in the form field when
// returning to the form.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_blockquote);
// Add the source editing plugin to the CKEditor 5 toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-available .ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Updating Source Editing's editable tags should automatically update
// filter_html to include those additional tags.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->waitForText('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<aside>');
$assert_session->assertWaitOnAjaxRequest();
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', '<br> <p> <h2> <h3> <h4> <h5> <h6> <aside> <strong> <em> <blockquote>');
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Adding tags to Source Editing's editable tags that are already supported
// by enabled CKEditor 5 plugins must trigger a validation error, and that
// error must be associated with the correct form item.
$source_edit_tags_field->setValue('<aside><strong>');
$assert_session->waitForText('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
$this->assertTrue($page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->getParent()->hasClass('is-selected'));
$this->assertSame('true', $page->findField('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]')->getAttribute('aria-invalid'));
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// The same validation error appears when saving the form regardless of the
// immediate AJAX validation error above.
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
$this->assertTrue($page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->getParent()->hasClass('is-selected'));
$this->assertSame('true', $page->findField('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]')->getAttribute('aria-invalid'));
$assert_session->pageTextNotContains('The text format ckeditor5 has been updated');
// Wait for the "Source editing" vertical tab to appear, remove the already
// supported tags and re-save. Now the text format should save successfully.
$assert_session->waitForText('Source editing');
$page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-sourceediting"]')->click();
$assert_session->pageTextContains('Manually editable HTML tags');
$source_edit_tags_field = $assert_session->fieldExists('editor[settings][plugins][ckeditor5_sourceEditing][allowed_tags]');
$source_edit_tags_field->setValue('<aside>');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$assert_session->pageTextContains('The text format ckeditor5 has been updated');
$assert_session->pageTextNotContains('The following tag(s) are already supported by enabled plugins and should not be added to the Source Editing "Manually editable HTML tags" field: Bold (<strong>)');
// Ensure that CKEditor can be initialized with Source Editing.
// @see https://www.drupal.org/i/3231427
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
}
/**
* Test that <drupal-media> is added to allowed tags when media embed enabled.
*/
public function testMediaElementAllowedTags(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.view_mode_2',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2',
])->save();
$this->createNewTextFormat($page, $assert_session);
// Allowed HTML field is readonly and its wrapper has a form-disabled class.
$this->assertNotEmpty($assert_session->waitForElement('css', '.js-form-item-filters-filter-html-settings-allowed-html.form-disabled'));
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertTrue($allowed_html_field->hasAttribute('readonly'));
// Allowed tags are currently the default, with no <drupal-media>.
$this->assertEquals($this->allowedElements, $allowed_html_field->getValue());
// Enable media embed.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$this->assertNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$page->checkField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(2);
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$page->clickLink('Embed media');
$page->checkField('filters[media_embed][settings][allowed_view_modes][view_mode_1]');
$page->checkField('filters[media_embed][settings][allowed_view_modes][view_mode_2]');
$allowed_with_media = $this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt data-view-mode>';
$allowed_with_media_without_view_mode = $this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt>';
$page->clickLink('Media');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][media_media][allow_view_mode_override]'));
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media_without_view_mode);
$page->checkField('editor[settings][plugins][media_media][allow_view_mode_override]');
$assert_session->assertExpectedAjaxRequest(3);
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media);
$this->saveNewTextFormat($page, $assert_session);
$assert_session->pageTextContains('Added text format ckeditor5.');
// Confirm <drupal-media> was added to allowed tags on save, as a result of
// enabling the media embed filter.
$this->assertSame($allowed_with_media, FilterFormat::load('ckeditor5')->filters('filter_html')->getConfiguration()['settings']['allowed_html']);
$page->find('css', '[data-drupal-selector="edit-formats-ckeditor5"]')->clickLink('Configure');
// Confirm that <drupal-media> is now included in the "Allowed tags" form
// field.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $allowed_with_media);
// Ensure that data-align attribute is added to <drupal-media> when
// filter_align is enabled.
$page->checkField('filters[filter_align][status]');
$assert_session->assertExpectedAjaxRequest(1);
$this->assertEquals($this->allowedElements . ' <drupal-media data-entity-type data-entity-uuid alt data-view-mode data-align>', $allowed_html_field->getValue());
// Disable media embed.
$this->assertTrue($page->hasCheckedField('filters[media_embed][status]'));
$page->uncheckField('filters[media_embed][status]');
$assert_session->assertExpectedAjaxRequest(2);
// Confirm allowed tags no longer has <drupal-media>.
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', $this->allowedElements);
}
/**
* Tests full HTML text format.
*/
public function testFullHtml(): void {
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.full_html.yml')
)->save();
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.basic_html.yml')
)->save();
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$page->fillField('body[0][value]', '<foo bar="baz">⬅️✌️➡️</foo><p><a style="color:#ff0000;" foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
$page->pressButton('Save');
// Configure Full HTML text format to use CKEditor 5.
$this->drupalGet('admin/config/content/formats/manage/full_html');
$page->checkField('roles[authenticated]');
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('The text format Full HTML has been updated.'));
// Change the node's text format to Full HTML.
$this->drupalGet('node/1/edit');
$filter_tips = $page->find('css', '[data-drupal-format-id="basic_html"]');
$this->assertTrue($filter_tips->isVisible());
$page->selectFieldOption('body[0][format]', 'full_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
// Check the visibility of "Filter tips" by clicking the "Cancel" button.
$page->pressButton('Cancel');
$this->assertTrue($filter_tips->isVisible());
$page->selectFieldOption('body[0][format]', 'full_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
// Ensure the editor is loaded and ensure that arbitrary markup is retained.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
// But note that the `style` attribute was stripped by
// \Drupal\editor\EditorXssFilter\Standard.
$assert_session->responseContains('<foo bar="baz">⬅️✌️➡️</foo><p><a foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
// Ensure attributes are retained after enabling link plugin.
$this->drupalGet('admin/config/content/formats/manage/full_html');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-link'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-link', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->drupalGet('node/1/edit');
$page->pressButton('Save');
$assert_session->responseContains('<p><a foo="bar" hreflang="en" href="https://example.com"><abbr title="National Aeronautics and Space Administration">NASA</abbr> is an acronym.</a></p>');
// Configure Basic HTML text format to use CKE5 and enable the link plugin.
$this->drupalGet('admin/config/content/formats/manage/basic_html');
$page->checkField('roles[authenticated]');
$page->selectFieldOption('editor[editor]', 'ckeditor5');
$assert_session->assertWaitOnAjaxRequest();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-available .ckeditor5-toolbar-item-underline'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-underline', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('The text format Basic HTML has been updated.'));
// Change the node's text format to Basic HTML.
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'basic_html');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
$assert_session->assertWaitOnAjaxRequest();
$page->pressButton('Save');
// The `style` and foo` attributes should have been removed, as should the
// `<abbr>` and `<foo>` tags.
$assert_session->responseContains('<p>⬅️✌️➡️</p><p><a href="https://example.com" hreflang="en">NASA is an acronym.</a></p>');
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Behat\Mink\Element\NodeElement;
use Drupal\editor\Entity\Editor;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
/**
* Tests code block configured languages are respected.
*
* @group ckeditor5
* @internal
*/
class CKEditor5CodeSyntaxTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* Tests code block configured languages are respected.
*/
public function testCKEditor5CodeSyntax(): void {
$this->addNewTextFormat();
/** @var \Drupal\editor\Entity\Editor $editor */
$editor = Editor::load('ckeditor5');
$editor->setSettings([
'toolbar' => [
'items' => [
'codeBlock',
],
],
'plugins' => [
'ckeditor5_codeBlock' => [
'languages' => [
['label' => 'Twig', 'language' => 'twig'],
['label' => 'YML', 'language' => 'yml'],
],
],
],
])->save();
$this->drupalGet('/node/add/page');
$this->waitForEditor();
// Open code block dropdown, and verify that correct languages are present.
$assertSession = $this->assertSession();
$page = $this->getSession()->getPage();
$page->find('css', '.ck-code-block-dropdown .ck-dropdown__button .ck-splitbutton__arrow')->click();
$codeBlockOptionsSelector = '.ck-code-block-dropdown .ck-dropdown__panel .ck-list__item .ck-button__label';
$assertSession->waitForElementVisible('css', $codeBlockOptionsSelector);
$codeBlockOptions = $page->findAll('css', $codeBlockOptionsSelector);
$this->assertCount(2, $codeBlockOptions);
$this->assertEquals([
'Twig',
'YML',
], \array_map(static fn (NodeElement $el) => $el->getText(), $codeBlockOptions));
}
}

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\user\RoleInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Tests for CKEditor 5 to ensure correct focus management in dialogs.
*
* @group ckeditor5
* @internal
*/
class CKEditor5DialogTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'ckeditor5_test',
];
/**
* Tests if CKEditor 5 tooltips can be interacted with in dialogs.
*/
public function testCKEditor5FocusInTooltipsInDialog(): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'CKEditor 5 with link',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'test_format',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => ['link'],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/ckeditor5_test/dialog');
$page->clickLink('Add Node');
$assert_session->waitForElementVisible('css', '[role="dialog"]');
$assert_session->assertWaitOnAjaxRequest();
$content_area = $assert_session->waitForElementVisible('css', '.ck-editor__editable');
// Focus the editable area first.
$content_area->click();
// Then press the button to add a link.
$this->pressEditorButton('Link');
$link_url = '/ckeditor5_test/dialog';
$input = $assert_session->waitForElementVisible('css', '.ck-balloon-panel input.ck-input-text');
// Make sure the input field can have focus and we can type into it.
$input->setValue($link_url);
// Save the new link.
$page->find('xpath', "//button[span[text()='Insert']]")->click();
// Make sure something was added to the text.
$this->assertNotEmpty($content_area->getText());
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\editor\Entity\Editor;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\User;
use Drupal\user\RoleInterface;
/**
* Tests that the fragment link points to CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5FragmentLinkTest extends WebDriverTestBase {
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'ckeditor5'];
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $account;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a text format and associate CKEditor 5.
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 with image upload',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
])->save();
// Create a node type for testing.
NodeType::create(['type' => 'page', 'name' => 'page'])->save();
$field_storage = FieldStorageConfig::loadByName('node', 'body');
// Create a body field instance for the 'page' node type.
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => 'Body',
'settings' => ['display_summary' => TRUE],
'required' => TRUE,
])->save();
// Assign widget settings for the 'default' form mode.
EntityFormDisplay::create([
'targetEntityType' => 'node',
'bundle' => 'page',
'mode' => 'default',
'status' => TRUE,
])->setComponent('body', ['type' => 'text_textarea_with_summary'])
->save();
$this->account = $this->drupalCreateUser([
'administer nodes',
'create page content',
]);
$this->drupalLogin($this->account);
}
/**
* Tests if the fragment link to a textarea works with CKEditor 5 enabled.
*/
public function testFragmentLink(): void {
$session = $this->getSession();
$web_assert = $this->assertSession();
$ckeditor_class = '.ck-editor';
$ckeditor_id = '#cke_edit-body-0-value';
$this->drupalGet('node/add/page');
// Add a bottom margin to the title field to be sure the body field is not
// visible.
$session->executeScript("document.getElementById('edit-title-0-value').style.marginBottom = window.innerHeight*2 +'px';");
$this->assertSession()->waitForElementVisible('css', $ckeditor_id);
// Check that the CKEditor 5-enabled body field is currently not visible in
// the viewport.
$web_assert->assertNotVisibleInViewport('css', $ckeditor_class, 'topLeft', 'CKEditor 5-enabled body field is visible.');
$before_url = $session->getCurrentUrl();
// Trigger a hash change with as target the hidden textarea.
$session->executeScript("location.hash = '#edit-body-0-value';");
// Check that the CKEditor 5-enabled body field is visible in the viewport.
// The hash change adds an ID to the CKEditor 5 instance so check its
// visibility using the ID now.
$web_assert->assertVisibleInViewport('css', $ckeditor_id, 'topLeft', 'CKEditor 5-enabled body field is not visible.');
// Use JavaScript to go back in the history instead of
// \Behat\Mink\Session::back() because that function doesn't work after a
// hash change.
$session->executeScript("history.back();");
$after_url = $session->getCurrentUrl();
// Check that going back in the history worked.
self::assertEquals($before_url, $after_url, 'History back works.');
}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
/**
* Tests ckeditor height respects field rows config.
*
* @group ckeditor5
* @internal
*/
class CKEditor5HeightTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* Tests editor height respects rows config.
*/
public function testCKEditor5Height(): void {
$this->addNewTextFormat();
/** @var \Drupal\editor\Entity\Editor $editor */
$editor = Editor::load('ckeditor5');
$editor->setSettings([
'toolbar' => [
'items' => [
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
])->save();
$this->drupalGet('/node/add/page');
$this->waitForEditor();
// We expect height to be 320, but test to ensure that it's greater
// than 300. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
$this->assertGreaterThan(300, $this->getEditorHeight());
// Check source editing height.
$this->pressEditorButton('Source');
$assert = $this->assertSession();
$this->assertNotNull($assert->waitForElementVisible('css', '.ck-source-editing-area'));
$this->assertGreaterThan(300, $this->getEditorHeight(TRUE));
// Test the max height of the editor is less that the window height.
$body = \str_repeat('<p>Llamas are cute.</p>', 100);
$node = $this->drupalCreateNode([
'body' => $body,
]);
$this->drupalGet($node->toUrl('edit-form'));
$this->assertLessThan($this->getWindowHeight(), $this->getEditorHeight());
// Check source editing has a scroll bar.
$this->pressEditorButton('Source');
$this->assertNotNull($assert->waitForElementVisible('css', '.ck-source-editing-area'));
$this->assertTrue($this->isSourceEditingScrollable());
// Double the editor row count.
\Drupal::service('entity_display.repository')->getFormDisplay('node', 'page')
->setComponent('body', [
'type' => 'text_textarea_with_summary',
'settings' => [
'rows' => 18,
],
])
->save();
// Check the height of the editor again.
$this->drupalGet('/node/add/page');
$this->waitForEditor();
// We expect height to be 640, but test to ensure that it's greater
// than 600. We want to ensure that we don't hard code a very specific
// value because tests might break if styles change (line-height, etc).
// Note that the default height for CKEditor5 is 47px.
$this->assertGreaterThan(600, $this->getEditorHeight());
}
/**
* Gets the height of ckeditor.
*/
private function getEditorHeight(bool $sourceEditing = FALSE): int {
$selector = $sourceEditing ? '.ck-source-editing-area' : '.ck-editor__editable';
$javascript = <<<JS
return document.querySelector('$selector').clientHeight;
JS;
return $this->getSession()->evaluateScript($javascript);
}
/**
* Gets the window height.
*/
private function getWindowHeight(): int {
$javascript = <<<JS
return window.innerHeight;
JS;
return $this->getSession()->evaluateScript($javascript);
}
/**
* Checks that the source editing element is scrollable.
*/
private function isSourceEditingScrollable(): bool {
$javascript = <<<JS
(function () {
const element = document.querySelector('.ck-source-editing-area textarea');
const style = window.getComputedStyle(element);
if (
element.scrollHeight > element.clientHeight &&
style.overflow !== 'hidden' &&
style['overflow-y'] !== 'hidden' &&
style.overflow !== 'clip' &&
style['overflow-y'] !== 'clip'
) {
if (
element === document.scrollingElement ||
(style.overflow !== 'visible' &&
style['overflow-y'] !== 'visible')
) {
return true;
}
}
return false;
})();
JS;
$evaluateScript = $this->getSession()->evaluateScript($javascript);
return $evaluateScript;
}
}

View File

@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\node\Entity\Node;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore esque māori sourceediting splitbutton upcasted
/**
* Tests for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5MarkupTest extends CKEditor5TestBase {
use TestFileCreationTrait;
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'media_library',
'language',
];
/**
* Ensures that attribute values are encoded.
*/
public function testAttributeEncoding(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 with image upload',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
'settings' => [
'toolbar' => [
'items' => ['drupalInsertImage'],
],
'plugins' => ['ckeditor5_imageResize' => ['allow_resize' => FALSE]],
],
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => NULL,
'max_dimensions' => [
'width' => NULL,
'height' => NULL,
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('ckeditor5'),
FilterFormat::load('ckeditor5')
))
));
$this->drupalGet('node/add/page');
$this->waitForEditor();
$page->fillField('title[0][value]', 'My test content');
// Ensure that CKEditor 5 is focused.
$this->click('.ck-content');
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
$assert_session->waitForElementVisible('css', '.ck-widget.image');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel .ck-text-alternative-form'));
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$this->assertSame('', $alt_override_input->getValue());
$alt_override_input->setValue('</em> Kittens & llamas are cute');
$this->getBalloonButton('Save')->click();
$page->pressButton('Save');
$uploaded_image = File::load(1);
$image_uuid = $uploaded_image->uuid();
$image_url = $this->container->get('file_url_generator')->generateString($uploaded_image->getFileUri());
$this->drupalGet('node/1');
$this->assertNotEmpty($assert_session->waitForElement('xpath', sprintf('//img[@alt="</em> Kittens & llamas are cute" and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_uuid)));
// Drupal CKEditor 5 integrations overrides the CKEditor 5 HTML writer to
// escape ampersand characters (&) and the angle brackets (< and >). This is
// required because \Drupal\Component\Utility\Xss::filter fails to parse
// element attributes with unescaped entities in value.
// @see https://www.drupal.org/project/drupal/issues/3227831
$this->assertEquals(sprintf('<img data-entity-uuid="%s" data-entity-type="file" src="%s" width="40" height="20" alt="&lt;/em&gt; Kittens &amp; llamas are cute">', $image_uuid, $image_url), Node::load(1)->get('body')->value);
}
/**
* Ensures that CKEditor 5 retains filter_html's allowed global attributes.
*
* FilterHtml always forbids the `style` and `on*` attributes, and always
* allows the `lang` attribute (with any value) and the `dir` attribute (with
* either `ltr` or `rtl` as value). It's important that those last two
* attributes are guaranteed to be retained.
*
* @see \Drupal\filter\Plugin\Filter\FilterHtml::getHTMLRestrictions()
* @see ckeditor5_globalAttributeDir
* @see ckeditor5_globalAttributeLang
* @see https://html.spec.whatwg.org/multipage/dom.html#global-attributes
*/
public function testFilterHtmlAllowedGlobalAttributes(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add/page');
$page->fillField('title[0][value]', 'Multilingual Hello World');
// cSpell:disable-next-line
$page->fillField('body[0][value]', '<p dir="ltr" lang="en">Hello World</p><p dir="rtl" lang="ar">مرحبا بالعالم</p>');
$page->pressButton('Save');
$this->addNewTextFormat();
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'ckeditor5');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
$this->waitForEditor();
$page->pressButton('Save');
// cSpell:disable-next-line
$assert_session->responseContains('<p dir="ltr" lang="en">Hello World</p><p dir="rtl" lang="ar">مرحبا بالعالم</p>');
}
/**
* Ensures that HTML comments are preserved in CKEditor 5.
*/
public function testComments(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$page->fillField('body[0][value]', '<!-- Hamsters, alpacas, llamas, and kittens are cute! --><p>This is a <em>test!</em></p>');
$page->pressButton('Save');
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 HTML comments test',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('ckeditor5'),
FilterFormat::load('ckeditor5')
))
));
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'ckeditor5');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
$assert_session->responseContains('<!-- Hamsters, alpacas, llamas, and kittens are cute! --><p>This is a <em>test!</em></p>');
}
/**
* Ensures that HTML scripts and styles are properly preserved in CKEditor 5.
*/
public function testStylesAndScripts(): void {
$test_cases = [
// Test cases taken from the HTML documentation.
// @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
'script' => [
'<script>(function() { let x = 10, y = 5; if( y <--x ) { console.log("run me!"); }})()</script>',
'<script>(function() { let x = 10, y = 5; if( y <--x ) { console.log("run me!"); }})()</script>',
],
'script like tag' => [
'<script>(function() { let player = 5, script = 10; if (player<script) { console.log("run me!"); }})()</script>',
'<script>(function() { let player = 5, script = 10; if (player<script) { console.log("run me!"); }})()</script>',
],
'script to escape' => [
"<script>const example = 'Consider this string: <!-- <script>';</script>",
"<script>const example = 'Consider this string: <!-- <script>';</script>",
],
'unescaped script tag' => [
<<<HTML
<script>
const example = 'Consider this string: <!-- <script>';
console.log(example);
</script>
<!-- despite appearances, this is actually part of the script still! -->
<script>
let a = 1 + 2; // this is the same script block still...
</script>
HTML,
<<<HTML
<script>
const example = 'Consider this string: <!-- <script>';
console.log(example);
</script>
<!-- despite appearances, this is actually part of the script still! -->
<script>
let a = 1 + 2; // this is the same script block still...
</script>
HTML,
],
'style' => [
<<<HTML
<style>
a > span {
/* Important comment. */
color: red !important;
}
</style>
HTML,
<<<HTML
<style>
a > span {
/* Important comment. */
color: red !important;
}
</style>
HTML,
],
'script and style' => [
<<<HTML
<script type="text/javascript">
let x = 10;
let y = 5;
if(y < x){
console.log('is smaller')
}
</script>
<style type="text/css">
:root {
--main-bg-color: brown;
}
.sections > .section {
background: var(--main-bg-color);
}
</style>
HTML,
<<<HTML
<script type="text/javascript">
let x = 10;
let y = 5;
if(y < x){
console.log('is smaller')
}
</script><style type="text/css">
:root {
--main-bg-color: brown;
}
.sections > .section {
background: var(--main-bg-color);
}
</style>
HTML,
],
];
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Create filter.
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 HTML',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('ckeditor5'),
FilterFormat::load('ckeditor5')
))
));
// Add a node with text rendered via the CKEditor 5 HTML format.
foreach ($test_cases as $test_case_name => $test_case) {
[$markup, $expected_content] = $test_case;
$this->drupalGet('node/add');
$page->fillField('title[0][value]', "Style and script test - $test_case_name");
$this->waitForEditor();
$this->pressEditorButton('Source');
$editor = $page->find('css', '.ck-source-editing-area textarea');
$editor->setValue($markup);
$page->pressButton('Save');
$assert_session->responseContains($expected_content);
}
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
/**
* Tests for CKEditor 5 to ensure correct styling in off-canvas.
*
* @group ckeditor5
* @internal
*/
class CKEditor5OffCanvasTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'ckeditor5_test',
];
/**
* Tests if CKEditor is properly styled inside an off-canvas dialog.
*/
public function testOffCanvasStyles(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->addNewTextFormat();
$this->drupalGet('/ckeditor5_test/off_canvas');
// The "Add Node" link triggers an off-canvas dialog with an add node form
// that includes CKEditor.
$page->clickLink('Add Node');
$assert_session->waitForElementVisible('css', '#drupal-off-canvas-wrapper');
$assert_session->assertWaitOnAjaxRequest();
$styles = $assert_session->elementExists('css', 'style#ckeditor5-off-canvas-reset');
$this->assertStringContainsString('#drupal-off-canvas-wrapper [data-drupal-ck-style-fence]', $styles->getHtml());
$assert_session->elementExists('css', '.ck');
$ckeditor_toolbar_bg_color = $this->getSession()->evaluateScript('window.getComputedStyle(document.querySelector(\'.ck.ck-toolbar\')).backgroundColor');
$this->assertEquals('rgb(255, 255, 255)', $ckeditor_toolbar_bg_color, 'Toolbar background-color should be unaffected by off-canvas');
// Editable area should be visible.
$assert_session->elementExists('css', '.ck .ck-content');
$ckeditor_editable_bg_color = $this->getSession()->evaluateScript('window.getComputedStyle(document.querySelector(\'.ck.ck-content\')).backgroundColor');
$this->assertEquals('rgb(255, 255, 255)', $ckeditor_editable_bg_color, 'Content background-color should be unaffected by off-canvas');
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
/**
* Tests read-only mode for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5ReadOnlyModeTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5_read_only_mode',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$field_storage = FieldStorageConfig::create([
'field_name' => 'field_second_ckeditor5_field',
'entity_type' => 'node',
'type' => 'text_with_summary',
'cardinality' => 1,
]);
$field_storage->save();
// Attach an instance of the field to the page content type.
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
'label' => 'Second CKEditor5 field',
])->save();
$this->container->get('entity_display.repository')
->getFormDisplay('node', 'page')
->setComponent('field_second_ckeditor5_field', [
'type' => 'text_textarea_with_summary',
])
->save();
}
/**
* Test that disabling a CKEditor 5 field results in an uneditable editor.
*/
public function testReadOnlyMode(): void {
$assert_session = $this->assertSession();
$this->addNewTextFormat();
// Check that both CKEditor 5 fields are editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'true');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'true');
$this->container->get('state')->set('ckeditor5_read_only_mode_body_enabled', TRUE);
// Check that the first body field is no longer editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'false');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'true');
$this->container->get('state')->set('ckeditor5_read_only_mode_second_ckeditor5_field_enabled', TRUE);
// Both fields are disabled, check that both fields are no longer editable.
$this->drupalGet('node/add');
$assert_session->elementAttributeContains('css', '.field--name-body .ck-editor .ck-content', 'contenteditable', 'false');
$assert_session->elementAttributeContains('css', '.field--name-field-second-ckeditor5-field .ck-editor .ck-content', 'contenteditable', 'false');
}
}

View File

@ -0,0 +1,729 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Core\Language\LanguageManager;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\node\Entity\Node;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\RoleInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore esque māori sourceediting splitbutton upcasted
/**
* Tests for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class CKEditor5Test extends CKEditor5TestBase {
use TestFileCreationTrait;
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'media_library',
'language',
];
/**
* Tests configuring CKEditor 5 for existing content.
*/
public function testExistingContent(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add/page');
$page->fillField('title[0][value]', 'My test content');
$page->fillField('body[0][value]', '<p>This is test content</p>');
$page->pressButton('Save');
$assert_session->responseNotContains('<p>This is test content</p>');
$assert_session->responseContains('&lt;p&gt;This is test content&lt;/p&gt;');
$this->addNewTextFormat();
// Change the node to use the new text format.
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'ckeditor5');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
// Ensure the editor is loaded.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
// Assert that the HTML is rendered correctly.
$assert_session->responseContains('<p>This is test content</p>');
$assert_session->responseNotContains('&lt;p&gt;This is test content&lt;/p&gt;');
}
/**
* Test headings configuration.
*/
public function testHeadingsPlugin(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->addNewTextFormat();
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', '<br> <p> <h2> <h3> <h4> <h5> <h6> <strong> <em>');
$this->drupalGet('node/add/page');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-heading-dropdown button'));
$page->find('css', '.ck-heading-dropdown button')->click();
// Get all the headings available in dropdown.
$headings_dropdown = $page->findAll('css', '.ck-heading-dropdown li .ck-button__label');
// Create array of available headings.
$available_headings = [];
foreach ($headings_dropdown as $item) {
$available_headings[] = $item->getText();
}
$this->assertSame([
'Paragraph',
'Heading 2',
'Heading 3',
'Heading 4',
'Heading 5',
'Heading 6',
], $available_headings);
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading1]'));
$page->checkField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading1]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertTrue($page->hasCheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading1]'));
$this->assertTrue($page->hasCheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading2]'));
$page->uncheckField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading2]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading2]'));
$this->assertTrue($page->hasCheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading4]'));
$page->uncheckField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading4]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading4]'));
$this->assertHtmlEsqueFieldValueEquals('filters[filter_html][settings][allowed_html]', '<br> <p> <h1> <h3> <h5> <h6> <strong> <em>');
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_heading][enabled_headings][heading4]'));
$page->pressButton('Save configuration');
$this->drupalGet('node/add/page');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-heading-dropdown button'));
$page->find('css', '.ck-heading-dropdown button')->click();
// Get all the headings available in dropdown.
$headings_dropdown = $page->findAll('css', '.ck-heading-dropdown li .ck-button__label');
// Create array of available headings.
$available_headings = [];
foreach ($headings_dropdown as $item) {
$available_headings[] = $item->getText();
}
$this->assertSame([
'Paragraph',
'Heading 1',
'Heading 3',
'Heading 5',
'Heading 6',
], $available_headings);
}
/**
* Test for Language of Parts plugin.
*/
public function testLanguageOfPartsPlugin(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->languageOfPartsPluginInitialConfigurationHelper($page, $assert_session);
// Test for "United Nations' official languages" option.
$languages = LanguageManager::getUnitedNationsLanguageList();
$this->languageOfPartsPluginConfigureLanguageListHelper($page, $assert_session, 'un');
$this->languageOfPartsPluginTestHelper($page, $assert_session, $languages);
// Test for "Drupal predefined languages" option.
$languages = LanguageManager::getStandardLanguageList();
$this->languageOfPartsPluginConfigureLanguageListHelper($page, $assert_session, 'all');
$this->languageOfPartsPluginTestHelper($page, $assert_session, $languages);
// Test for "Site-configured languages" option.
ConfigurableLanguage::createFromLangcode('ar')->save();
ConfigurableLanguage::createFromLangcode('fr')->save();
ConfigurableLanguage::createFromLangcode('mi')->setName('Māori')->save();
$configured_languages = \Drupal::languageManager()->getLanguages();
$languages = [];
foreach ($configured_languages as $language) {
$language_name = $language->getName();
$language_code = $language->getId();
$languages[$language_code] = [$language_name];
}
$this->languageOfPartsPluginConfigureLanguageListHelper($page, $assert_session, 'site_configured');
$this->languageOfPartsPluginTestHelper($page, $assert_session, $languages);
}
/**
* Helper to configure CKEditor5 with Language plugin.
*/
public function languageOfPartsPluginInitialConfigurationHelper($page, $assert_session): void {
$this->createNewTextFormat($page, $assert_session);
// Press arrow down key to add the button to the active toolbar.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-textPartLanguage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-textPartLanguage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The CKEditor 5 module should warn that `<span>` cannot be created.
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="warning"]:contains("The Language plugin needs another plugin to create <span>, for it to be able to create the following attributes: <span lang dir>. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// Make `<span>` creatable.
$this->assertNotEmpty($assert_session->elementExists('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<span>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Dispatching an `input` event does not work in WebDriver. Enabling another
// toolbar item which has no associated HTML elements forces it.
$this->triggerKeyUp('.ckeditor5-toolbar-item-undo', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// Confirm there are no longer any warnings.
$assert_session->waitForElementRemoved('css', '[data-drupal-messages] [role="alert"]');
$page->pressButton('Save configuration');
$assert_session->responseContains('Added text format <em class="placeholder">ckeditor5</em>.');
}
/**
* Helper to set language list option for CKEditor.
*/
public function languageOfPartsPluginConfigureLanguageListHelper($page, $assert_session, $option): void {
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertNotEmpty($assert_session->waitForElement('css', 'a[href^="#edit-editor-settings-plugins-ckeditor5-language"]'));
// Set correct value.
$vertical_tab_link = $page->find('xpath', "//ul[contains(@class, 'vertical-tabs__menu')]/li/a[starts-with(@href, '#edit-editor-settings-plugins-ckeditor5-language')]");
$vertical_tab_link->click();
$select = $page->findField('editor[settings][plugins][ckeditor5_language][language_list]');
if ($select->getValue() !== $option) {
$select->selectOption($option);
$assert_session->assertWaitOnAjaxRequest();
}
$page->pressButton('Save configuration');
$assert_session->responseContains('The text format <em class="placeholder">ckeditor5</em> has been updated.');
}
/**
* Validate expected languages available in editor.
*/
public function languageOfPartsPluginTestHelper($page, $assert_session, $configured_languages): void {
$this->drupalGet('node/add/page');
$this->assertNotEmpty($assert_session->waitForText('Choose language'));
// Click on the dropdown button.
$page->find('css', '.ck-text-fragment-language-dropdown button')->click();
// Get all the languages available in dropdown.
$current_languages = $page->findAll('css', '.ck-text-fragment-language-dropdown li .ck-button__label');
// Remove "Remove language" element from current languages.
array_shift($current_languages);
// Create array of full language name.
$languages = [];
foreach ($current_languages as $item) {
$languages[] = $item->getText();
}
// Return the values from a single column.
$configured_languages = array_column($configured_languages, 0);
// Sort on full language name.
asort($configured_languages);
$this->assertSame(array_values($configured_languages), $languages);
}
/**
* Gets the titles of the vertical tabs in the given container.
*
* @param string $container_selector
* The container in which to look for vertical tabs.
* @param bool $visible_only
* (optional) Whether to restrict to only the visible vertical tabs. TRUE by
* default.
*
* @return string[]
* The titles of all vertical tabs menu items, restricted to only
* visible ones by default.
*
* @throws \LogicException
*/
private function getVerticalTabs(string $container_selector, bool $visible_only = TRUE): array {
$page = $this->getSession()->getPage();
// Ensure the container exists.
$container = $page->find('css', $container_selector);
if ($container === NULL) {
throw new \LogicException('The given container should exist.');
}
// Make sure that the container selector contains exactly one Vertical Tabs
// UI component.
$vertical_tabs = $container->findAll('css', '.vertical-tabs');
if (count($vertical_tabs) != 1) {
throw new \LogicException('The given container should contain exactly one Vertical Tabs component.');
}
$vertical_tabs = $container->findAll('css', '.vertical-tabs__menu-item');
$vertical_tabs_titles = [];
foreach ($vertical_tabs as $vertical_tab) {
if ($visible_only && !$vertical_tab->isVisible()) {
continue;
}
$title = $vertical_tab->find('css', '.vertical-tabs__menu-item-title')->getHtml();
// When retrieving visible vertical tabs, mark the selected one.
if ($visible_only && $vertical_tab->hasClass('is-selected')) {
$title = "➡️$title";
}
$vertical_tabs_titles[] = $title;
}
return $vertical_tabs_titles;
}
/**
* Enables a disabled CKEditor 5 toolbar item.
*
* @param string $toolbar_item_id
* The toolbar item to enable.
*/
protected function enableDisabledToolbarItem(string $toolbar_item_id): void {
$assert_session = $this->assertSession();
$assert_session->elementExists('css', ".ckeditor5-toolbar-disabled .ckeditor5-toolbar-item-$toolbar_item_id");
$this->triggerKeyUp(".ckeditor5-toolbar-item-$toolbar_item_id", 'ArrowDown');
$assert_session->elementNotExists('css', ".ckeditor5-toolbar-disabled .ckeditor5-toolbar-item-$toolbar_item_id");
$assert_session->elementExists('css', ".ckeditor5-toolbar-active .ckeditor5-toolbar-item-$toolbar_item_id");
}
/**
* Confirms active tab status is intact after AJAX refresh.
*/
public function testActiveTabsMaintained(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// Initial vertical tabs: 3 for filters, 1 for CKE5 plugins.
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'Convert URLs into links',
'Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper', FALSE));
$this->assertSame([
'Headings',
], $this->getVerticalTabs('#plugin-settings-wrapper', FALSE));
// Initial visible vertical tabs: 1 for filters, 1 for CKE5 plugins.
$this->assertSame([
'➡Limit allowed HTML tags and correct faulty HTML',
], $this->getVerticalTabs('#filter-settings-wrapper'));
$this->assertSame([
'➡Headings',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// Enable media embed to make a second filter config vertical tab visible.
$this->assertTrue($page->hasUncheckedField('filters[media_embed][status]'));
$this->assertNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$page->checkField('filters[media_embed][status]');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector=edit-filters-media-embed-settings]', 0));
$assert_session->assertWaitOnAjaxRequest();
// Filter plugins vertical tabs behavior: the filter plugin settings
// vertical tab with the heaviest filter weight is active by default.
// Hence enabling the media_embed filter (weight 100) results in its
// vertical tab being activated (filter_html's weight is -10).
// @see core/modules/filter/filter.admin.js
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'➡Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper'));
$this->assertSame([
'➡Headings',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// Enable upload image to add a third (and fourth) CKE5 plugin vertical tab.
$this->enableDisabledToolbarItem('drupalInsertImage');
$assert_session->assertWaitOnAjaxRequest();
// The active CKE5 plugin settings vertical tab is unchanged.
$this->assertSame([
'➡Headings',
'Image',
'Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// The active filter plugin settings vertical tab is unchanged.
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'➡Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper'));
// Open the CKE5 "Image" plugin settings vertical tab, interact with the
// subform and observe that the AJAX requests those interactions trigger do
// not change the active vertical tabs.
$page->clickLink('Image');
$assert_session->waitForText('Enable image uploads');
$this->assertSame([
'Headings',
'➡Image',
'Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
$this->assertTrue($page->hasUncheckedField('editor[settings][plugins][ckeditor5_image][status]'));
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->assertSame([
'Headings',
'➡Image',
'Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'➡Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper'));
$page->pressButton('Save configuration');
$assert_session->pageTextContains('Added text format ckeditor5');
// Leave and return to the config form, wait for initialized Vertical Tabs.
$this->drupalGet('admin/config/content/formats/');
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$assert_session->waitForElement('css', '.vertical-tabs__menu-item.is-selected');
// The first CKE5 plugin settings vertical tab is active by default.
$this->assertSame([
'➡Headings',
'Image',
'Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// Filter plugins vertical tabs behavior: the filter plugin settings
// vertical tab with the heaviest filter weight is active by default.
// Hence enabling the media_embed filter (weight 100) results in its
// vertical tab being activated (filter_html's weight is -10).
// @see core/modules/filter/filter.admin.js
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'➡Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper'));
// Click the 3rd CKE5 plugin vertical tab.
$page->clickLink($this->getVerticalTabs('#plugin-settings-wrapper')[2]);
$this->assertSame([
'Headings',
'Image',
'➡Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// Add another CKEditor 5 toolbar item just to trigger an AJAX refresh.
$this->enableDisabledToolbarItem('blockQuote');
$assert_session->assertWaitOnAjaxRequest();
// The active CKE5 plugin settings vertical tab is unchanged.
$this->assertSame([
'Headings',
'Image',
'➡Image resize',
'Media',
], $this->getVerticalTabs('#plugin-settings-wrapper'));
// The active filter plugin settings vertical tab is unchanged.
$this->assertSame([
'Limit allowed HTML tags and correct faulty HTML',
'➡Embed media',
], $this->getVerticalTabs('#filter-settings-wrapper'));
}
/**
* Ensures that CKEditor 5 integrates with file reference filter.
*/
public function testEditorFileReferenceIntegration(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$page->clickLink('Image');
$page->checkField('editor[settings][plugins][ckeditor5_image][status]');
$assert_session->assertWaitOnAjaxRequest();
$page->checkField('filters[editor_file_reference][status]');
$assert_session->assertWaitOnAjaxRequest();
$this->saveNewTextFormat($page, $assert_session);
$this->drupalGet('node/add/page');
$page->fillField('title[0][value]', 'My test content');
// Ensure that CKEditor 5 is focused.
$this->click('.ck-content');
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
// Wait until preview for the image has rendered to ensure that the image
// upload has completed and the image has been downcast.
// @see https://www.drupal.org/project/drupal/issues/3250587
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-content img[data-entity-uuid]'));
// Add alt text to the image.
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.image.ck-widget > img'));
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel .ck-text-alternative-form'));
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$alt_override_input->setValue('There is now alt text');
$this->getBalloonButton('Save')->click();
$page->pressButton('Save');
$uploaded_image = File::load(1);
$image_url = $this->container->get('file_url_generator')->generateString($uploaded_image->getFileUri());
$image_uuid = $uploaded_image->uuid();
$assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @width="40" and @height="20" and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
// Ensure that width, height, and length attributes are not stored in the
// database.
$this->assertEquals(sprintf('<img data-entity-uuid="%s" data-entity-type="file" src="%s" width="40" height="20" alt="There is now alt text">', $image_uuid, $image_url), Node::load(1)->get('body')->value);
// Ensure that data-entity-uuid and data-entity-type attributes are upcasted
// correctly to CKEditor model.
$this->drupalGet('node/1/edit');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
$assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @width="40" and @height="20" and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
}
/**
* Ensures that CKEditor italic model is converted to em.
*/
public function testEmphasis(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Add a node with text rendered via the Plain Text format.
$this->drupalGet('node/add/page');
$page->fillField('title[0][value]', 'My test content');
$page->fillField('body[0][value]', '<p>This is a <em>test!</em></p>');
$page->pressButton('Save');
$this->addNewTextFormat();
$this->drupalGet('node/1/edit');
$page->selectFieldOption('body[0][format]', 'ckeditor5');
$this->assertNotEmpty($assert_session->waitForText('Change text format?'));
$page->pressButton('Continue');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
$assert_session->responseContains('<p>This is a <em>test!</em></p>');
}
/**
* Tests list plugin.
*/
public function testListPlugin(): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'CKEditor 5 with list',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'test_format',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => ['sourceEditing', 'numberedList'],
],
'plugins' => [
'ckeditor5_list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$ordered_list_html = '<ol><li>apple</li><li>banana</li><li>cantaloupe</li></ol>';
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('node/add/page');
$page->fillField('title[0][value]', 'My test content');
$this->pressEditorButton('Source');
$source_text_area = $assert_session->waitForElement('css', '.ck-source-editing-area textarea');
$source_text_area->setValue($ordered_list_html);
// Click source again to make source inactive and have the numbered list
// splitbutton active.
$this->pressEditorButton('Source');
$numbered_list_dropdown_selector = '.ck-splitbutton__arrow';
// Check that there is no dropdown available for the numbered list because
// both reversed and startIndex are FALSE.
$assert_session->elementNotExists('css', $numbered_list_dropdown_selector);
// Save content so source content is kept after changing the editor config.
$page->pressButton('Save');
$edit_url = $this->getSession()->getCurrentURL() . '/edit';
$this->drupalGet($edit_url);
$this->waitForEditor();
// Enable the reversed functionality.
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
$settings['plugins']['ckeditor5_list']['properties']['reversed'] = TRUE;
$editor->setSettings($settings);
$editor->save();
$this->getSession()->reload();
$this->waitForEditor();
$this->click($numbered_list_dropdown_selector);
$reversed_order_button_selector = '.ck.ck-button.ck-numbered-list-properties__reversed-order';
$assert_session->elementExists('css', $reversed_order_button_selector);
$assert_session->elementTextEquals('css', $reversed_order_button_selector, 'Reversed order');
$start_index_element_selector = '.ck.ck-numbered-list-properties__start-index';
$assert_session->elementNotExists('css', $start_index_element_selector);
// Have both the reversed and the start index enabled.
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
$settings['plugins']['ckeditor5_list']['properties']['startIndex'] = TRUE;
$editor->setSettings($settings);
$editor->save();
$this->getSession()->reload();
$this->waitForEditor();
$this->click($numbered_list_dropdown_selector);
$assert_session->elementExists('css', $reversed_order_button_selector);
$assert_session->elementTextEquals('css', $reversed_order_button_selector, 'Reversed order');
$assert_session->elementExists('css', $start_index_element_selector);
}
/**
* Ensures that changes are saved in CKEditor 5.
*/
public function testSave(): void {
// To replicate the bug from https://www.drupal.org/i/3396742
// We need 2 or more text formats and node edit page.
FilterFormat::create([
'format' => 'ckeditor5',
'name' => 'CKEditor 5 HTML',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('ckeditor5'),
FilterFormat::load('ckeditor5')
))
));
FilterFormat::create([
'format' => 'ckeditor5_2',
'name' => 'CKEditor 5 HTML 2',
'roles' => [RoleInterface::AUTHENTICATED_ID],
])->save();
Editor::create([
'format' => 'ckeditor5_2',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('ckeditor5_2'),
FilterFormat::load('ckeditor5_2')
))
));
$this->drupalCreateNode([
'title' => 'My test content',
]);
// Test that entered text is saved.
$this->drupalGet('node/1/edit');
$page = $this->getSession()->getPage();
$this->waitForEditor();
$editor = $page->find('css', '.ck-content');
$editor->setValue('Very important information');
$page->pressButton('Save');
$this->assertSession()->responseContains('Very important information');
// Test that changes only in source are saved.
$this->drupalGet('node/1/edit');
$page = $this->getSession()->getPage();
$this->waitForEditor();
$this->pressEditorButton('Source');
$editor = $page->find('css', '.ck-source-editing-area textarea');
$editor->setValue('Text hidden in the source');
$page->pressButton('Save');
$this->assertSession()->responseContains('Text hidden in the source');
}
}

View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Behat\Mink\Element\TraversableElement;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\RoleInterface;
// cspell:ignore esque
/**
* Base class for testing CKEditor 5.
*
* @ingroup testing
* @internal
*/
abstract class CKEditor5TestBase extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page']);
$this->drupalLogin($this->drupalCreateUser([
'administer filters',
'create page content',
'edit own page content',
]));
}
/**
* Add and save a new text format using CKEditor 5.
*/
protected function addNewTextFormat($name = 'ckeditor5'): void {
FilterFormat::create([
'format' => $name,
'roles' => [RoleInterface::AUTHENTICATED_ID],
'name' => $name,
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<br> <p> <h2> <h3> <h4> <h5> <h6> <strong> <em>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => $name,
'format' => $name,
'image_upload' => [
'status' => FALSE,
],
])->save();
}
/**
* Create a new text format using CKEditor 5.
*/
public function createNewTextFormat($page, $assert_session, $name = 'ckeditor5') {
$this->drupalGet('admin/config/content/formats/add');
$page->fillField('name', $name);
$assert_session->waitForText('Machine name');
$this->assertNotEmpty($assert_session->waitForText($name));
$page->checkField('roles[authenticated]');
if ($name === 'ckeditor5') {
// Enable the HTML filter, at least one HTML restricting filter is needed
// before CKEditor 5 can be enabled.
$this->assertTrue($page->hasUncheckedField('filters[filter_html][status]'));
$page->checkField('filters[filter_html][status]');
// Add the tags that must be included in the html filter for CKEditor 5.
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$allowed_html_field->setValue('<p> <br>');
}
$page->selectFieldOption('editor[editor]', $name);
$assert_session->assertExpectedAjaxRequest(1);
}
/**
* Save the new text format.
*/
public function saveNewTextFormat($page, $assert_session) {
$page->pressButton('Save configuration');
$this->assertTrue($assert_session->waitForText('Added text format'), "Confirm new text format saved");
}
/**
* Trigger a keyup event on the selected element.
*
* @param string $selector
* The css selector for the element.
* @param string $key
* The keyCode.
*/
protected function triggerKeyUp(string $selector, string $key) {
$script = <<<JS
(function (selector, key) {
const btn = document.querySelector(selector);
btn.dispatchEvent(new KeyboardEvent('keydown', { key }));
btn.dispatchEvent(new KeyboardEvent('keyup', { key }));
})('{$selector}', '{$key}')
JS;
$options = [
'script' => $script,
'args' => [],
];
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
}
/**
* Decorates ::fieldValueEquals() to force DrupalCI to provide useful errors.
*
* @param string $field
* Field id|name|label|value.
* @param string $value
* Field value.
* @param \Behat\Mink\Element\TraversableElement $container
* Document to check against.
*
* @throws \Behat\Mink\Exception\ExpectationException
*
* @see \Behat\Mink\WebAssert::fieldValueEquals()
*/
protected function assertHtmlEsqueFieldValueEquals($field, $value, ?TraversableElement $container = NULL) {
$assert_session = $this->assertSession();
$node = $assert_session->fieldExists($field, $container);
$actual = $node->getValue();
$regex = '/^' . preg_quote($value, '/') . '$/ui';
$message = sprintf('The field "%s" value is "%s", but "%s" expected.', $field, htmlspecialchars($actual), htmlspecialchars($value));
$assert_session->assert((bool) preg_match($regex, $actual), $message);
}
/**
* Checks that no real-time validation errors are present.
*
* @throws \Behat\Mink\Exception\ElementNotFoundException
*/
protected function assertNoRealtimeValidationErrors(): void {
$assert_session = $this->assertSession();
$this->assertSame('', $assert_session->elementExists('css', '[data-drupal-selector="ckeditor5-realtime-validation-messages-container"]')->getHtml());
}
}

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\Entity\User;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Tests for CKEditor 5 editor UI with Toolbar module.
*
* @group ckeditor5
* @internal
*/
class CKEditor5ToolbarTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'ckeditor5',
'toolbar',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The admin user.
*
* @var \Drupal\user\Entity\User
*/
protected User $user;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'image_upload' => [
'status' => FALSE,
],
'settings' => [],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->drupalCreateContentType([
'type' => 'article',
'name' => 'Article',
]);
$this->user = $this->drupalCreateUser([
'use text format test_format',
'access toolbar',
'edit any article content',
'administer site configuration',
]);
$this->drupalLogin($this->user);
}
/**
* Ensures that CKEditor 5 toolbar renders below Drupal Toolbar.
*/
public function test(): void {
$assert_session = $this->assertSession();
// Create test content to ensure that CKEditor 5 text editor can be
// scrolled.
$body = '';
for ($i = 0; $i < 10; $i++) {
$body .= '<p>' . $this->randomMachineName(32) . '</p>';
}
$edit_url = $this->drupalCreateNode(['type' => 'article', 'body' => ['value' => $body, 'format' => 'test_format']])->toUrl('edit-form');
$this->drupalGet($edit_url);
$this->assertNotEmpty($assert_session->waitForElement('css', '#toolbar-bar'));
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure the body has enough height to enable scrolling. Scroll 110px from
// top of body field to ensure CKEditor 5 toolbar is sticky.
$this->getSession()->evaluateScript('document.body.style.height = "10000px";');
$this->getSession()->evaluateScript('location.hash = "#edit-body-0-value";');
$this->getSession()->evaluateScript('scroll(0, document.documentElement.scrollTop + 110);');
// Focus CKEditor 5 text editor.
$javascript = <<<JS
Drupal.CKEditor5Instances.get(document.getElementById("edit-body-0-value").dataset["ckeditor5Id"]).editing.view.focus();
JS;
$this->getSession()->evaluateScript($javascript);
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-sticky-panel__placeholder'));
$toolbar_height = (int) $this->getSession()->evaluateScript('document.getElementById("toolbar-bar").offsetHeight');
$ckeditor5_toolbar_position = (int) $this->getSession()->evaluateScript("document.querySelector('.ck-toolbar').getBoundingClientRect().top");
$this->assertEqualsWithDelta($toolbar_height, $ckeditor5_toolbar_position, 2);
}
}

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Tests emphasis in CKEditor 5.
*
* CKEditor's use of <i> is converted to <em> in Drupal, so additional coverage
* is provided here to verify successful conversion.
*
* @group ckeditor5
* @internal
*/
class EmphasisTest extends WebDriverTestBase {
use CKEditor5TestTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field to use the <em> tag in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em>',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [
'italic',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
$this->drupalCreateContentType(['type' => 'blog']);
$this->host = $this->createNode([
'type' => 'blog',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>This is a <em>test!</em></p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Ensures that CKEditor italic model is converted to em.
*/
public function testEmphasis(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$emphasis_element = $assert_session->waitForElementVisible('css', '.ck-content p em');
$this->assertEquals('test!', $emphasis_element->getText());
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$emphasis_source = $xpath->query('//p/em');
$this->assertNotEmpty($emphasis_source);
$this->assertEquals('test!', $emphasis_source[0]->textContent);
$page->pressButton('Save');
$assert_session->responseContains('<p>This is a <em>test!</em></p>');
}
/**
* Tests that arbitrary attributes are allowed via GHS.
*/
public function testEmphasisArbitraryHtml(): void {
$assert_session = $this->assertSession();
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
// Allow the data-foo attribute in img via GHS.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<em data-foo>'];
$editor->setSettings($settings);
$editor->save();
// Add data-foo use to an existing em tag.
$original_value = $this->host->body->value;
$this->host->body->value = str_replace('<em>', '<em data-foo="bar">', $original_value);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$emphasis_element = $assert_session->waitForElementVisible('css', '.ck-content p em');
$this->assertEquals('bar', $emphasis_element->getAttribute('data-foo'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//em[@data-foo="bar"]'));
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore imageresize imageupload
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageTest extends ImageTestTestBase {
use ImageTestBaselineTrait;
/**
* Tests the ckeditor5_imageResize and ckeditor5_imageUpload settings forms.
*/
public function testImageSettingsForm(): void {
$assert_session = $this->assertSession();
$this->drupalGet('admin/config/content/formats/manage/test_format');
// The image resize and upload plugin settings forms should be present.
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
// Removing the drupalImageInsert button from the toolbar must remove the
// plugin settings forms too.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowUp');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
// Re-adding the drupalImageInsert button to the toolbar must re-add the
// plugin settings forms too.
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-imageresize"]');
$assert_session->elementExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-image"]');
}
/**
* Tests that it's possible to upload SVG image, with the test module enabled.
*/
public function testCanUploadSvg(): void {
$this->container->get('module_installer')
->install(['ckeditor5_test_module_allowed_image']);
$page = $this->getSession()->getPage();
$src = 'core/modules/ckeditor5/tests/fixtures/test-svg-upload.svg';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image_upload_field->attachFile($this->container->get('file_system')->realpath($src));
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image-inline > img[src$="test-svg-upload.svg"]'));
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
// cspell:ignore imageresize
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @internal
*/
abstract class ImageTestBase extends CKEditor5TestBase {
use CKEditor5TestTrait;
use TestFileCreationTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field to embed images in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Provides the relevant image attributes.
*
* @return string[]
* An associative array with the image source, width, and height.
*/
protected function imageAttributes() {
return [
'src' => base_path() . 'core/misc/druplicon.png',
'width' => '88',
'height' => '100',
];
}
/**
* Helper to format attributes.
*
* @param bool $reverse
* Reverse attributes when printing them.
*
* @return string
* A space-separated string of image attributes.
*/
protected function imageAttributesAsString($reverse = FALSE) {
$string = [];
foreach ($this->imageAttributes() as $key => $value) {
$string[] = $key . '="' . $value . '"';
}
if ($reverse) {
$string = array_reverse($string);
}
return implode(' ', $string);
}
/**
* Add an image to the CKEditor 5 editable zone.
*/
protected function addImage() {
$page = $this->getSession()->getPage();
$src = $this->imageAttributes()['src'];
$this->waitForEditor();
$this->pressEditorButton('Insert image via URL');
$dialog = $page->find('css', '.ck-dialog');
$src_input = $dialog->find('css', '.ck-image-insert-url input[type=text]');
$src_input->setValue($src);
$dialog->find('xpath', "//button[span[text()='Insert']]")->click();
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image > img[src="' . $src . '"]'));
}
}

View File

@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Component\Utility\Html;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
// cspell:ignore imageresize
/**
* Trait with common test methods for image tests.
*/
trait ImageTestBaselineTrait {
/**
* Ensures that attributes are retained on conversion.
*/
public function testAttributeRetentionDuringUpcasting(): void {
// Run test cases in a single test to make the test run faster.
$attributes_to_retain = [
'-none-' => 'inline',
'data-caption="test caption 🦙"' => 'block',
'data-align="left"' => 'inline',
];
foreach ($attributes_to_retain as $attribute_to_retain => $expected_upcast_behavior_when_wrapped_in_block_element) {
if ($attribute_to_retain === '-none-') {
$attribute_to_retain = '';
}
$img_tag = '<img ' . $attribute_to_retain . ' alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' />';
$test_cases = [
// Plain image tag for a baseline.
[
$img_tag,
$img_tag,
],
// Image tag wrapped with <p>.
[
"<p>$img_tag</p>",
$expected_upcast_behavior_when_wrapped_in_block_element === 'inline' ? "<p>$img_tag</p>" : $img_tag,
],
// Image tag wrapped with a disallowed paragraph-like element (<div).
// When inline is the expected upcast behavior, it will wrap in <p>
// because it still must wrap in a paragraph-like element, and <p> is
// available to be that element.
[
"<div>$img_tag</div>",
$expected_upcast_behavior_when_wrapped_in_block_element === 'inline' ? "<p>$img_tag</p>" : $img_tag,
],
];
foreach ($test_cases as $test_case) {
[$markup, $expected] = $test_case;
$this->host->body->value = $markup;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Ensure that the image is rendered in preview.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', ".ck-content .ck-widget img"));
$editor_dom = $this->getEditorDataAsDom();
$expected_dom = Html::load($expected);
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEquals($expected_dom->getElementsByTagName('body')->item(0)->C14N(), $editor_dom->getElementsByTagName('body')->item(0)->C14N());
// Ensure the test attribute is persisted on downcast.
if ($attribute_to_retain) {
$this->assertNotEmpty($xpath->query("//img[@$attribute_to_retain]"));
}
}
}
}
/**
* Tests that arbitrary attributes are allowed via GHS.
*/
public function testImageArbitraryHtml(): void {
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
// Allow the data-foo attribute in img via GHS.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<img data-foo>'];
$editor->setSettings($settings);
$editor->save();
$format = FilterFormat::load('test_format');
$original_config = $format->filters('filter_html')
->getConfiguration();
foreach ($this->providerLinkability() as $data) {
[$image_type, $unrestricted] = $data;
$format_config = $unrestricted ? ['status' => FALSE] : $original_config;
$format->setFilterConfig('filter_html', $format_config)
->save();
// Make the test content have either a block image or an inline image.
$img_tag = '<img data-foo="bar" alt="drupalimage test image" data-entity-type="file" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value .= $image_type === 'block'
? $img_tag
: "<p>$img_tag</p>";
$this->host->save();
$expected_widget_selector = $image_type === 'block' ? 'image img' : 'image-inline';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$drupalimage = $this->assertSession()->waitForElementVisible('css', ".ck-content .ck-widget.$expected_widget_selector");
$this->assertNotEmpty($drupalimage);
$this->assertEquals('bar', $drupalimage->getAttribute('data-foo'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@data-foo="bar"]'));
}
}
/**
* Tests linkability of the image CKEditor widget.
*
* Due to the complex overrides that `drupalImage.DrupalImage` is making, this
* is explicitly testing the "editingDowncast" and "dataDowncast" results.
* These are CKEditor 5 concepts.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*/
public function testLinkability(): void {
$format = FilterFormat::load('test_format');
$original_config = $format->filters('filter_html')
->getConfiguration();
$original_body_value = $this->host->body->value;
foreach ($this->providerLinkability() as $data) {
[$image_type, $unrestricted] = $data;
assert($image_type === 'inline' || $image_type === 'block');
$format_config = $unrestricted ? ['status' => FALSE] : $original_config;
$format->setFilterConfig('filter_html', $format_config)
->save();
// Make the test content have either a block image or an inline image.
$img_tag = '<img alt="drupalimage test image" data-entity-type="file" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value = $original_body_value . ($image_type === 'block'
? $img_tag
: "<p>$img_tag</p>");
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$page = $this->getSession()->getPage();
// Adjust the expectations accordingly.
$expected_widget_class = $image_type === 'block' ? 'image' : 'image-inline';
$this->waitForEditor();
$assert_session = $this->assertSession();
// Initial state: the image CKEditor Widget is not selected.
$drupalimage = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.$expected_widget_class");
$this->assertNotEmpty($drupalimage);
$this->assertFalse($drupalimage->hasClass('.ck-widget_selected'));
$src = basename($this->imageAttributes()['src']);
// Assert the "editingDowncast" HTML before making changes.
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]');
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertEmpty($xpath->query('//a'));
// Assert the link button is present and not pressed.
$link_button = $this->getEditorButton('Link');
$this->assertSame('false', $link_button->getAttribute('aria-pressed'));
// Tests linking images.
$drupalimage->click();
$this->assertTrue($drupalimage->hasClass('ck-widget_selected'));
$this->assertEditorButtonEnabled('Link');
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Image toolbar"]');
$link_image_button = $this->getBalloonButton('Link image');
// Click the "Link image" button.
$this->assertSame('false', $link_image_button->getAttribute('aria-pressed'));
$link_image_button->press();
// Assert structure of link form balloon.
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]');
// Fill in link form balloon's <input> and hit "Insert".
$url_input->setValue('http://www.drupal.org/association');
$balloon->pressButton('Insert');
// Assert the "editingDowncast" HTML after making changes. First assert
// the link exists, then assert the expected DOM structure in detail.
$assert_session->elementExists('css', '.ck-content a[href*="//www.drupal.org/association"]');
// For inline images, the link is wrapping the widget; for block images
// the link lives inside the widget. (This is how it is implemented
// upstream, it could be implemented differently, we just want to ensure
// we do not break it. Drupal only cares about having its own
// "dataDowncast", the "editingDowncast" is considered an implementation
// detail.)
$assert_session->elementExists('css', $image_type === 'inline'
? '.ck-content a[href*="//www.drupal.org/association"] .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]'
: '.ck-content .ck-widget.' . $expected_widget_class . ' a[href*="//www.drupal.org/association"] > img[src*="' . $src . '"][alt="drupalimage test image"]'
);
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount(1, $xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertEmpty($xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Add `class="trusted"` to the link.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
$this->pressEditorButton('Source');
$source_text_area = $assert_session->waitForElement('css', '.ck-source-editing-area textarea');
$this->assertNotEmpty($source_text_area);
$new_value = str_replace('<a ', '<a class="trusted" ', $source_text_area->getValue());
$source_text_area->setValue('<p>temp</p>');
$source_text_area->setValue($new_value);
$this->pressEditorButton('Source');
// When unrestricted, additional attributes on links should be retained.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Save the entity whose text field is being edited.
$page->pressButton('Save');
// Assert the HTML the end user sees.
$assert_session->elementExists('css', $unrestricted
? 'a[href="http://www.drupal.org/association"].trusted img[src*="' . $src . '"]'
: 'a[href="http://www.drupal.org/association"] img[src*="' . $src . '"]');
// Go back to edit the now *linked* <drupal-media>. Everything from this
// point onwards is effectively testing "upcasting" and proving there is
// no data loss.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://www.drupal.org/association"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://www.drupal.org/association" and @class="trusted"]'));
// Tests unlinking images.
$drupalimage->click();
$this->assertEditorButtonEnabled('Link');
$this->assertSame('true', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Image toolbar"]');
$link_image_button = $this->getBalloonButton('Link image');
$this->assertSame('true', $link_image_button->getAttribute('aria-pressed'));
$link_image_button->click();
// Assert structure of link actions balloon.
$this->getBalloonButton('Edit link');
$unlink_image_button = $this->getBalloonButton('Unlink');
// Click the "Unlink" button.
$unlink_image_button->click();
$this->assertSame('false', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert the "editingDowncast" HTML after making changes. Assert the
// widget exists but not the link, or *any* link for that matter. Then
// assert the expected DOM structure in detail.
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class);
$assert_session->elementNotExists('css', '.ck-content a');
$assert_session->elementExists('css', '.ck-content .ck-widget.' . $expected_widget_class . ' > img[src*="' . $src . '"][alt="drupalimage test image"]');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount(0, $xpath->query('//a[@href="http://www.drupal.org/association"]/img[@alt="drupalimage test image"]'));
$this->assertCount(1, $xpath->query('//img[@alt="drupalimage test image"]'));
$this->assertCount(0, $xpath->query('//a'));
}
}
/**
* Returns data for testLinkability() and testImageArbitraryHtml().
*/
protected function providerLinkability(): array {
return [
'BLOCK image, restricted' => ['block', FALSE],
'BLOCK image, unrestricted' => ['block', TRUE],
'INLINE image, restricted' => ['inline', FALSE],
'INLINE image, unrestricted' => ['inline', TRUE],
];
}
/**
* Ensures that images can have caption set.
*/
public function testImageCaption(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// The foo attribute is added to be removed later by CKEditor 5 to make sure
// CKEditor 5 was able to downcast data.
$img_tag = '<img ' . $this->imageAttributesAsString() . ' alt="drupalimage test image" data-caption="Alpacas &lt;em&gt;are&lt;/em&gt; cute&lt;br&gt;really!" foo="bar">';
$this->host->body->value = $img_tag;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$this->assertNotEmpty($figcaption = $assert_session->waitForElement('css', '.image figcaption'));
$this->assertSame('Alpacas <em>are</em> cute<br>really!', $figcaption->getHtml());
$page->pressButton('Source');
$editor_dom = $this->getEditorDataAsDom();
$data_caption = $editor_dom->getElementsByTagName('img')->item(0)->getAttribute('data-caption');
$this->assertSame('Alpacas <em>are</em> cute<br>really!', $data_caption);
$page->pressButton('Save');
$src = $this->imageAttributes()['src'];
$expected = '<img ' . $this->imageAttributesAsString(TRUE) . ' alt="drupalimage test image" data-caption="Alpacas &lt;em&gt;are&lt;/em&gt; cute&lt;br&gt;really!">';
$expected_dom = Html::load($expected);
$this->assertEquals($expected_dom->getElementsByTagName('body')->item(0)->C14N(), $editor_dom->getElementsByTagName('body')->item(0)->C14N());
$assert_session->elementExists('xpath', '//figure/img[@src="' . $src . '" and not(@data-caption)]');
$assert_session->responseContains('<figcaption>Alpacas <em>are</em> cute<br>really!</figcaption>');
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageTestProviderTest extends ImageTestTestBase {
use ImageTestProviderTrait;
}

View File

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
// cspell:ignore imageresize
/**
* Provides test methods using data providers for image tests.
*/
trait ImageTestProviderTrait {
/**
* Tests that alt text is required for images.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerAltTextRequired
*/
public function testAltTextRequired(bool $unrestricted): void {
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
// Make the test content has a block image and an inline image.
$img_tag = preg_replace(
'/width="\d+" height="\d+"/',
'width="500"',
'<img ' . $this->imageAttributesAsString() . ' />'
);
$this->host->body->value .= $img_tag . "<p>$img_tag</p>";
$this->host->save();
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Confirm both of the images exist.
$this->assertNotEmpty($image_block = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image"));
$this->assertNotEmpty($image_inline = $assert_session->waitForElementVisible('css', ".ck-content .ck-widget.image-inline"));
// Confirm both of the images have an alt text required warning.
$this->assertNotEmpty($image_block->find('css', '.image-alternative-text-missing-wrapper'));
$this->assertNotEmpty($image_inline->find('css', '.image-alternative-text-missing-wrapper'));
// Add alt text to the block image.
$image_block->find('css', '.image-alternative-text-missing button')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-balloon-panel'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
// Ensure that the missing alt text warning is hidden when the alternative
// text form is open.
$assert_session->waitForElement('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing');
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is not added to decorative images.
$this->assertNotEmpty($decorative_button = $this->getBalloonButton('Decorative image'));
$assert_session->elementExists('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]');
$decorative_button->click();
$assert_session->elementExists('css', '.ck-content .ck-widget.image .image-alternative-text-missing.ck-hidden');
$assert_session->elementExists('css', ".ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper");
$assert_session->elementNotExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing.ck-hidden');
// Ensure that the missing alt text error is removed after saving the
// changes.
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$save_button->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', ".ck-content .ck-widget.image .image-alternative-text-missing-wrapper"));
$assert_session->elementExists('css', '.ck-content .ck-widget.image-inline .image-alternative-text-missing-wrapper');
// Ensure that the decorative image downcasts into empty alt attribute.
$editor_dom = $this->getEditorDataAsDom();
$decorative_img = $editor_dom->getElementsByTagName('img')->item(0);
$this->assertTrue($decorative_img->hasAttribute('alt'));
$this->assertEmpty($decorative_img->getAttribute('alt'));
// Ensure that missing alt text error is not added to images with alt text.
$this->assertNotEmpty($alt_text_button = $this->getBalloonButton('Change image alternative text'));
$alt_text_button->click();
$decorative_button->click();
$this->assertNotEmpty($save_button = $this->getBalloonButton('Save'));
$this->assertTrue($save_button->hasClass('ck-disabled'));
$this->assertNotEmpty($alt_override_input = $page->find('css', '.ck-balloon-panel .ck-text-alternative-form input[type=text]'));
$alt_override_input->setValue('There is now alt text');
$this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-balloon-panel .ck-text-alternative-form .ck-disabled'));
$this->assertFalse($save_button->hasClass('ck-disabled'));
$save_button->click();
// Save the node and confirm that the alt text is retained.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', 'img[alt="There is now alt text"]'));
// Ensure that alt form is opened after image upload.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->addImage();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-text-alternative-form'));
$this->assertVisibleBalloon('.ck-text-alternative-form');
}
/**
* Providers data for testAltTextRequired().
*/
public static function providerAltTextRequired(): array {
return [
'Restricted' => [FALSE],
'Unrestricted' => [TRUE],
];
}
/**
* Tests alignment integration.
*
* @dataProvider providerAlignment
*/
public function testAlignment(string $image_type): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
// Make the test content have either a block image or an inline image.
$img_tag = '<img alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' />';
$this->host->body->value .= $image_type === 'block'
? $img_tag
: "<p>$img_tag</p>";
$this->host->save();
$image_selector = $image_type === 'block' ? '.ck-widget.image' : '.ck-widget.image-inline';
$default_alignment = $image_type === 'block' ? 'Break text' : 'In line';
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $image_selector));
// Ensure that the default alignment option matches expectation.
$this->click($image_selector);
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->assertTrue($this->getBalloonButton($default_alignment)->hasClass('ck-on'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertFalse($drupal_media_element->hasAttribute('data-align'));
$this->getBalloonButton('Align center and break text')->click();
// Assert the alignment class exists after editing downcast.
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.image.image-style-align-center'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertEquals('center', $drupal_media_element->getAttribute('data-align'));
$page->pressButton('Save');
// Check that the 'content has been updated' message status appears to
// confirm we left the editor.
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '[data-drupal-messages]'));
// Check that the class is correct in the front end.
$assert_session->elementExists('css', 'img.align-center');
// Go back to the editor to check that the alignment class still exists.
$edit_url = $this->getSession()->getCurrentURL() . '/edit';
$this->drupalGet($edit_url);
$this->waitForEditor();
$assert_session->elementExists('css', '.ck-widget.image.image-style-align-center');
// Ensure that "Centered image" alignment option is selected.
$this->click('.ck-widget.image');
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->assertTrue($this->getBalloonButton('Align center and break text')->hasClass('ck-on'));
$this->getBalloonButton('Break text')->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', '.ck-widget.image.image-style-align-center'));
$editor_dom = $this->getEditorDataAsDom();
$drupal_media_element = $editor_dom->getElementsByTagName('img')
->item(0);
$this->assertFalse($drupal_media_element->hasAttribute('data-align'));
}
/**
* Data provider for testAlignment().
*/
public static function providerAlignment() {
return [
'Block image' => ['block'],
'Inline image' => ['inline'],
];
}
/**
* Ensures that width attribute upcasts and downcasts correctly.
*
* @param string $width
* The width input for the image.
*
* @dataProvider providerWidth
*/
public function testWidth(string $width): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Despite the absence of a `height` attribute on the `<img>`, CKEditor 5
// should generate an appropriate `height`, matching with the aspect ratio
// of the image.
$expected_computed_height = $width;
if (!str_ends_with($width, '%')) {
$ratio = $width / (int) $this->imageAttributes()['width'];
$expected_computed_height = (string) (int) round($ratio * (int) $this->imageAttributes()['height']);
}
// Add image to the host body.
$this->host->body->value = sprintf('<img data-foo="bar" alt="drupalimage test image" ' . $this->imageAttributesAsString() . ' width="%s" />', $width);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Ensure that the image is upcast as expected. In the editing view, the
// width attribute should downcast to an inline style on the container
// element.
$assert_session->waitForElementVisible('css', ".ck-widget.image");
$this->assertNotEmpty($assert_session->waitForElementVisible('css', ".ck-widget.image[style] img"));
// Ensure that the width attribute is retained on downcast.
$editor_data = $this->getEditorDataAsDom();
$img_in_editor = $editor_data->getElementsByTagName('img')->item(0);
$this->assertSame($width, $img_in_editor->getAttribute('width'));
$this->assertSame($expected_computed_height, $img_in_editor->getAttribute('height'));
// Save the node and ensure that the width attribute is retained, and ensure
// that a natural image ratio-respecting height attribute has been added.
$page->pressButton('Save');
$this->assertNotEmpty($assert_session->waitForElement('css', "img[width='$width'][height='$expected_computed_height']"));
}
/**
* Data provider for ::testWidth().
*
* @return string[][]
* An array of test cases, each with a width value.
*/
public static function providerWidth(): array {
return [
'Image resize with percent unit (only allowed in HTML 4)' => [
'width' => '33%',
],
'Image resize with (implied) px unit' => [
'width' => '100',
],
];
}
/**
* Tests the image resize plugin.
*
* Confirms that enabling the resize plugin introduces the resize class to
* images within CKEditor 5.
*
* @param bool $is_resize_enabled
* Boolean flag to test enabled or disabled.
*
* @dataProvider providerResize
*/
public function testResize(bool $is_resize_enabled): void {
// Disable resize plugin because it is enabled by default.
if (!$is_resize_enabled) {
Editor::load('test_format')->setSettings([
'toolbar' => [
'items' => [
'drupalInsertImage',
],
],
'plugins' => [
'ckeditor5_imageResize' => [
'allow_resize' => FALSE,
],
],
])->save();
}
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('node/add');
$page->fillField('title[0][value]', 'My test content');
$this->addImage();
$selector = $is_resize_enabled ? 'figure.ck-widget_with-resizer' : 'figure:not(.ck-widget_with-resizer)';
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $selector));
}
/**
* Data provider for ::testResize().
*
* @return array
* The test cases.
*/
public static function providerResize(): array {
return [
'Image resize is enabled' => [
'is_resize_enabled' => TRUE,
],
'Image resize is disabled' => [
'is_resize_enabled' => FALSE,
],
];
}
}

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore imageresize imageupload
/**
* Provides a base class for testing CKEditor 5 image embedding and uploads.
*
* @internal
*/
abstract class ImageTestTestBase extends ImageTestBase {
/**
* The sample image File entity to embed.
*
* @var \Drupal\file\FileInterface
*/
protected $file;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em> <a href> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
'sourceEditing',
'link',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
],
],
'image_upload' => [
'status' => TRUE,
'scheme' => 'public',
'directory' => 'inline-images',
'max_size' => '1M',
'max_dimensions' => ['width' => 100, 'height' => 100],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
'administer filters',
]);
// Create a sample host entity to embed images in.
$this->file = File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
]);
$this->file->save();
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>The pirate is irate.</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Provides the relevant image attributes.
*
* @return string[]
* Default image attributes for tests.
*/
protected function imageAttributes(): array {
return [
'data-entity-type' => 'file',
'data-entity-uuid' => $this->file->uuid(),
'src' => $this->file->createFileUrl(),
'width' => '40',
'height' => '20',
];
}
/**
* Uploads a test image.
*/
protected function addImage() {
$page = $this->getSession()->getPage();
$this->assertNotEmpty($image_upload_field = $page->find('css', '.ck-file-dialog-button input[type="file"]'));
$image = $this->getTestFiles('image')[0];
$image_upload_field->attachFile($this->container->get('file_system')->realpath($image->uri));
// Wait for the image to be uploaded and rendered by CKEditor 5.
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image > img[src*="' . $image->filename . '"]'));
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageUrlProviderTest extends ImageUrlTestBase {
use ImageTestProviderTrait;
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
* @group ckeditor5
* @group #slow
* @internal
*/
class ImageUrlTest extends ImageUrlTestBase {
use ImageTestBaselineTrait;
/**
* Tests the Drupal image URL widget.
*/
public function testImageUrlWidget(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$image_selector = '.ck-widget.image-inline';
$src = $this->imageAttributes()['src'];
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->pressEditorButton('Insert image via URL');
$dialog = $page->find('css', '.ck-dialog');
$src_input = $dialog->find('css', '.ck-image-insert-url input[type=text]');
$src_input->setValue($src);
$dialog->find('xpath', "//button[span[text()='Insert']]")->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $image_selector));
$this->click($image_selector);
$this->assertVisibleBalloon('[aria-label="Image toolbar"]');
$this->pressEditorButton('Update image URL');
$dialog = $page->find('css', '.ck-dialog');
$src_input = $dialog->find('css', '.ck-image-insert-url input[type=text]');
$this->assertEquals($src, $src_input->getValue());
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore imageresize
/**
* Provides a base class for testing CKEditor 5 image URL insertion.
*
* @internal
*/
abstract class ImageUrlTestBase extends ImageTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <em> <a href> <img alt height width src data-caption data-align>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'drupalInsertImage',
'sourceEditing',
'link',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
'administer filters',
]);
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>The pirate is irate.</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
// cspell:ignore drupalmediatoolbar
/**
* Tests for CKEditor 5 plugins using Drupal's translation system.
*
* @group ckeditor5
* @internal
*/
class JSTranslationTest extends CKEditor5TestBase {
use MediaTypeCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'locale',
'media_library',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image', 'label' => 'Image']);
}
/**
* Integration test to ensure that CKEditor 5 Plugins translations are loaded.
*/
public function test(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalMedia'));
$this->click('#edit-filters-media-embed-status');
$assert_session->assertExpectedAjaxRequest(2);
$this->triggerKeyUp('.ckeditor5-toolbar-item-drupalMedia', 'ArrowDown');
$assert_session->assertExpectedAjaxRequest(3);
$this->saveNewTextFormat($page, $assert_session);
$langcode = 'fr';
ConfigurableLanguage::createFromLangcode($langcode)->save();
$this->config('system.site')->set('default_langcode', $langcode)->save();
// Visit a page that will trigger a JavaScript file parsing for
// translatable strings.
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure a string from the CKEditor 5 plugin is picked up by translation.
// @see core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediatoolbar.js
$locale_storage = $this->container->get('locale.storage');
$string = $locale_storage->findString(['source' => 'Drupal Media toolbar', 'context' => '']);
$this->assertNotEmpty($string, 'String from JavaScript file saved.');
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\language\Entity\ConfigurableLanguage;
// cspell:ignore คำพูดบล็อก sourceediting
/**
* Tests for CKEditor 5 UI translations.
*
* @group ckeditor5
* @internal
*/
class LanguageTest extends CKEditor5TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'locale',
];
/**
* Integration test to ensure that CKEditor 5 UI translations are loaded.
*
* @param string $langcode
* The language code.
* @param string $toolbar_item_name
* The CKEditor 5 plugin to enable.
* @param string $toolbar_item_translation
* The expected translation for CKEditor 5 plugin toolbar button.
*
* @dataProvider provider
*/
public function test(string $langcode, string $toolbar_item_name, string $toolbar_item_translation): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// Special case: textPartLanguage toolbar item can only create `<span lang>`
// but not `<span>`. The purpose of this test is to test translations, not
// the configuration of the textPartLanguage functionality. So, make sure
// that `<span>` can be created so we can test how UI translations work when
// using `textPartLanguage`.
if ($toolbar_item_name === 'textPartLanguage') {
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and
// should have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<span>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
}
$this->assertNotEmpty($assert_session->waitForElement('css', ".ckeditor5-toolbar-item-$toolbar_item_name"));
$this->triggerKeyUp(".ckeditor5-toolbar-item-$toolbar_item_name", 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$this->saveNewTextFormat($page, $assert_session);
ConfigurableLanguage::createFromLangcode($langcode)->save();
$this->config('system.site')->set('default_langcode', $langcode)->save();
$this->drupalGet('node/add');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
// Ensure that blockquote button is translated.
$assert_session->elementExists('xpath', "//span[text()='$toolbar_item_translation']");
}
/**
* Data provider for ensuring CKEditor 5 UI translations are loaded.
*
* @return string[][]
* An array of language code, CKEditor 5 plugin name, and expected
* translation.
*/
public static function provider(): array {
return [
'Language code both in Drupal and CKEditor' => [
'langcode' => 'th',
'toolbar_item_name' => 'blockQuote',
'toolbar_item_translation' => 'คำพูดบล็อก',
],
'Language code transformed from browser mappings' => [
'langcode' => 'zh-hans',
'toolbar_item_name' => 'blockQuote',
'toolbar_item_translation' => '块引用',
],
'Language configuration conflict' => [
'langcode' => 'fr',
'toolbar_item_name' => 'textPartLanguage',
// cSpell:disable-next-line
'toolbar_item_translation' => 'Choisir la langue',
],
];
}
}

View File

@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\media\Entity\Media;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore arrakis complote détruire harkonnen
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary
* @group ckeditor5
* @internal
*/
class MediaLibraryTest extends WebDriverTestBase {
use MediaTypeCreationTrait;
use TestFileCreationTrait;
use CKEditor5TestTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* The media item to embed.
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'media_library',
'node',
'media',
'text',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'media_embed' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [
'drupalMedia',
'sourceEditing',
'undo',
'redo',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'media_media' => [
'allow_view_mode_override' => FALSE,
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->drupalCreateContentType(['type' => 'blog']);
// Note that media_install() grants 'view media' to all users by default.
$this->user = $this->drupalCreateUser([
'use text format test_format',
'access media overview',
'create blog content',
]);
// Create a media type that starts with the letter a, to test tab order.
$this->createMediaType('image', ['id' => 'arrakis', 'label' => 'Arrakis']);
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image', 'label' => 'Image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->media = Media::create([
'bundle' => 'image',
'name' => 'Fear is the mind-killer',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->media->save();
$arrakis_media = Media::create([
'bundle' => 'arrakis',
'name' => 'Le baron Vladimir Harkonnen',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'Il complote pour détruire le duc Leto',
'title' => 'Il complote pour détruire le duc Leto',
],
],
]);
$arrakis_media->save();
$this->drupalLogin($this->user);
}
/**
* Tests using drupalMedia button to embed media into CKEditor 5.
*/
public function testButton(): void {
$media_preview_selector = '.ck-content .ck-widget.drupal-media .media';
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
// Ensure that the tab order is correct.
$tabs = $page->findAll('css', '.media-library-menu__link');
$expected_tab_order = [
'Show Image media (selected)',
'Show Arrakis media',
];
foreach ($tabs as $key => $tab) {
$this->assertSame($expected_tab_order[$key], $tab->getText());
}
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$expected_attributes = [
'data-entity-type' => 'media',
'data-entity-uuid' => $this->media->uuid(),
];
foreach ($expected_attributes as $name => $expected) {
$this->assertSame($expected, $drupal_media->getAttribute($name));
}
$this->assertEditorButtonEnabled('Undo');
$this->pressEditorButton('Undo');
$this->assertEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$this->assertEditorButtonDisabled('Undo');
$this->pressEditorButton('Redo');
$this->assertEditorButtonEnabled('Undo');
// Ensure that data-align attribute is set by default when media is inserted
// while filter_align is enabled.
FilterFormat::load('test_format')
->setFilterConfig('filter_align', ['status' => TRUE])
->save();
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', $media_preview_selector, 1000));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$expected_attributes = [
'data-entity-type' => 'media',
'data-entity-uuid' => $this->media->uuid(),
];
foreach ($expected_attributes as $name => $expected) {
$this->assertSame($expected, $drupal_media->getAttribute($name));
}
// Ensure that by default, data-align attribute is not set.
$this->assertFalse($drupal_media->hasAttribute('data-align'));
}
/**
* Tests the allowed media types setting on the MediaEmbed filter.
*/
public function testAllowedMediaTypes(): void {
$test_cases = [
'all_media_types' => [],
'only_image' => ['image' => 'image'],
'only_arrakis' => ['arrakis' => 'arrakis'],
'both_items_checked' => [
'image' => 'image',
'arrakis' => 'arrakis',
],
];
foreach ($test_cases as $allowed_media_types) {
// Update the filter format to set the allowed media types.
FilterFormat::load('test_format')
->setFilterConfig('media_embed', [
'status' => TRUE,
'settings' => [
'allowed_media_types' => $allowed_media_types,
],
])->save();
// Now test opening the media library from the CKEditor plugin, and
// verify the expected behavior.
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-wrapper'));
if (empty($allowed_media_types) || count($allowed_media_types) === 2) {
$menu = $assert_session->elementExists('css', '.js-media-library-menu');
$assert_session->elementExists('named', ['link', 'Image'], $menu);
$assert_session->elementExists('named', ['link', 'Arrakis'], $menu);
$assert_session->elementTextContains('css', '.js-media-library-item', 'Fear is the mind-killer');
}
elseif (count($allowed_media_types) === 1 && !empty($allowed_media_types['image'])) {
// No tabs should appear if there's only one media type available.
$assert_session->elementNotExists('css', '.js-media-library-menu');
$assert_session->elementTextContains('css', '.js-media-library-item', 'Fear is the mind-killer');
}
elseif (count($allowed_media_types) === 1 && !empty($allowed_media_types['arrakis'])) {
// No tabs should appear if there's only one media type available.
$assert_session->elementNotExists('css', '.js-media-library-menu');
$assert_session->elementTextContains('css', '.js-media-library-item', 'Le baron Vladimir Harkonnen');
}
}
}
/**
* Ensures that alt text can be changed on Media Library inserted Media.
*/
public function testAlt(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->drupalGet('/node/add/blog');
$this->waitForEditor();
$this->pressEditorButton('Insert Media');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-modal #media-library-content'));
$assert_session->elementExists('css', '.js-media-library-item')->click();
$assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Insert selected');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.drupal-media img'));
// Test that clicking the media widget triggers a CKEditor balloon panel
// with a single button to override the alt text.
$this->click('.ck-widget.drupal-media');
$this->assertVisibleBalloon('[aria-label="Drupal Media toolbar"]');
// Click the "Override media image text alternative" button.
$this->getBalloonButton('Override media image alternative text')->click();
$this->assertVisibleBalloon('.ck-media-alternative-text-form');
// Assert that the value is currently empty.
$alt_override_input = $page->find('css', '.ck-balloon-panel .ck-media-alternative-text-form input[type=text]');
$this->assertSame('', $alt_override_input->getValue());
$test_alt = 'Alt text override';
$alt_override_input->setValue($test_alt);
$this->getBalloonButton('Save')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ck-widget.drupal-media img[alt*="' . $test_alt . '"]'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
$this->assertEquals($test_alt, $drupal_media->getAttribute('alt'));
// Test that the media item can be designated 'decorative'.
// Click the "Override media image text alternative" button.
$this->getBalloonButton('Override media image alternative text')->click();
$page->pressButton('Decorative image');
$this->getBalloonButton('Save')->click();
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$drupal_media = $xpath->query('//drupal-media')[0];
// The alt text in CKEditor displays alt="&quot;&quot;", indicating
// decorative image (https://www.w3.org/WAI/tutorials/images/decorative/).
$this->assertEquals('""', $drupal_media->getAttribute('alt'));
}
}

View File

@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media
* @group ckeditor5
* @internal
*/
class MediaLinkabilityTest extends MediaTestBase {
/**
* Ensures arbitrary attributes can be added on links wrapping media via GHS.
*
* @dataProvider providerLinkability
*/
public function testLinkedMediaArbitraryHtml(bool $unrestricted): void {
$assert_session = $this->assertSession();
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
$filter_format = $editor->getFilterFormat();
if ($unrestricted) {
$filter_format
->setFilterConfig('filter_html', ['status' => FALSE]);
}
else {
// Allow the data-foo attribute in <a> via GHS. Also, add support for
// div's with data-foo attribute to ensure that linked drupal-media
// elements can be wrapped with <div>.
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'] = ['<a data-foo>', '<div data-bar>'];
$editor->setSettings($settings);
$filter_format->setFilterConfig('filter_html', [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <strong> <em> <a href data-foo> <drupal-media data-entity-type data-entity-uuid data-align data-caption alt data-view-mode> <div data-bar>',
],
]);
}
$editor->save();
$filter_format->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Wrap the existing drupal-media tag with a div and an a that include
// attributes allowed via GHS.
$original_value = $this->host->body->value;
$this->host->body->value = '<div data-bar="baz"><a href="https://example.com" data-foo="bar">' . $original_value . '</a></div>';
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
// Confirm data-foo is present in the editing view.
$this->assertNotEmpty($link = $assert_session->waitForElementVisible('css', 'a[href="https://example.com"]'));
$this->assertEquals('bar', $link->getAttribute('data-foo'));
// Confirm that the media is wrapped by the div on the editing view.
$assert_session->elementExists('css', 'div[data-bar="baz"] > .drupal-media > a[href="https://example.com"] > div[data-drupal-media-preview]');
// Confirm that drupal-media is wrapped by the div and a, and that GHS has
// retained arbitrary HTML allowed by source editing.
$editor_dom = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($editor_dom->query('//div[@data-bar="baz"]/a[@data-foo="bar"]/drupal-media'));
}
/**
* Tests linkability of the media CKEditor widget.
*
* Due to the very different HTML markup generated for the editing view and
* the data view, this is explicitly testing the "editingDowncast" and
* "dataDowncast" results. These are CKEditor 5 concepts.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion
*
* @dataProvider providerLinkability
*/
public function testLinkability(bool $unrestricted): void {
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
}
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
// Initial state: the Drupal Media CKEditor Widget is not selected.
$drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media');
$this->assertNotEmpty($drupalmedia);
$this->assertFalse($drupalmedia->hasClass('.ck-widget_selected'));
// Assert the "editingDowncast" HTML before making changes.
$assert_session->elementExists('css', '.ck-content .ck-widget.drupal-media > [data-drupal-media-preview]');
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertEmpty($xpath->query('//a'));
// Assert the link button is present and not pressed.
$link_button = $this->getEditorButton('Link');
$this->assertSame('false', $link_button->getAttribute('aria-pressed'));
// Wait for the preview to load.
$preview = $assert_session->waitForElement('css', '.ck-content .ck-widget.drupal-media [data-drupal-media-preview="ready"]');
$this->assertNotEmpty($preview);
// Tests linking Drupal media.
$drupalmedia->click();
$this->assertTrue($drupalmedia->hasClass('ck-widget_selected'));
$this->assertEditorButtonEnabled('Link');
// Assert structure of image toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$link_media_button = $this->getBalloonButton('Link media');
// Click the "Link media" button.
$this->assertSame('false', $link_media_button->getAttribute('aria-pressed'));
$link_media_button->press();
// Assert structure of link form balloon.
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]');
// Fill in link form balloon's <input> and hit "Insert".
$url_input->setValue('http://linking-embedded-media.com');
$balloon->pressButton('Insert');
// Assert the "editingDowncast" HTML after making changes. Assert the link
// exists, then assert the link exists. Then assert the expected DOM
// structure in detail.
$assert_session->elementExists('css', '.ck-content a[href="http://linking-embedded-media.com"]');
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > a[href="http://linking-embedded-media.com"] > div[aria-label] > article > div > img[src*="image-test.png"]');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media'));
// Ensure that the media caption is retained and not linked as a result of
// linking media.
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media[@data-caption="baz"]'));
// Add `class="trusted"` to the link.
$this->assertEmpty($xpath->query('//a[@href="http://linking-embedded-media.com" and @class="trusted"]'));
$this->pressEditorButton('Source');
$source_text_area = $assert_session->waitForElement('css', '.ck-source-editing-area textarea');
$this->assertNotEmpty($source_text_area);
$new_value = str_replace('<a ', '<a class="trusted" ', $source_text_area->getValue());
$source_text_area->setValue('<p>temp</p>');
$source_text_area->setValue($new_value);
$this->pressEditorButton('Source');
// When unrestricted, additional attributes on links should be retained.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertCount($unrestricted ? 1 : 0, $xpath->query('//a[@href="http://linking-embedded-media.com" and @class="trusted"]'));
// Save the entity whose text field is being edited.
$page->pressButton('Save');
// Assert the HTML the end user sees.
$assert_session->elementExists('css', $unrestricted
? 'a[href="http://linking-embedded-media.com"].trusted img[src*="image-test.png"]'
: 'a[href="http://linking-embedded-media.com"] img[src*="image-test.png"]');
// Go back to edit the now *linked* <drupal-media>. Everything from this
// point onwards is effectively testing "upcasting" and proving there is no
// data loss.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Assert the "dataDowncast" HTML before making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]'));
$this->assertNotEmpty($xpath->query('//a[@href="http://linking-embedded-media.com"]/drupal-media'));
// Tests unlinking media.
$drupalmedia->click();
$this->assertEditorButtonEnabled('Link');
$this->assertSame('true', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert structure of Drupal media toolbar balloon.
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$link_media_button = $this->getBalloonButton('Link media');
$this->assertSame('true', $link_media_button->getAttribute('aria-pressed'));
$link_media_button->click();
// Assert structure of link actions balloon.
$this->getBalloonButton('Edit link');
$unlink_image_button = $this->getBalloonButton('Unlink');
// Click the "Unlink" button.
$unlink_image_button->click();
$this->assertSame('false', $this->getEditorButton('Link')->getAttribute('aria-pressed'));
// Assert the "editingDowncast" HTML after making changes. Assert the link
// exists, then assert no link exists. Then assert the expected DOM
// structure in detail.
$assert_session->elementNotExists('css', '.ck-content a');
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > div[aria-label] > article > div > img[src*="image-test.png"]');
// Ensure that figcaption exists.
// @see https://www.drupal.org/project/drupal/issues/3268318
$assert_session->elementExists('css', '.ck-content .drupal-media.ck-widget > figcaption');
// Assert the "dataDowncast" HTML after making changes.
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query('//drupal-media'));
$this->assertEmpty($xpath->query('//a'));
}
/**
* Returns data for multiple tests.
*
* Provides data for testLinkability(), testLinkManualDecorator() and
* testLinkedMediaArbitraryHtml().
*/
public static function providerLinkability(): array {
return [
'restricted' => [FALSE],
'unrestricted' => [TRUE],
];
}
/**
* Ensure that manual link decorators work with linkable media.
*
* @dataProvider providerLinkability
*/
public function testLinkManualDecorator(bool $unrestricted): void {
\Drupal::service('module_installer')->install(['ckeditor5_manual_decorator_test']);
$this->resetAll();
$decorator = 'Open in a new tab';
$decorator_attributes = '[@target="_blank"][@rel="noopener noreferrer"][@class="link-new-tab"]';
// Disable filter_html.
if ($unrestricted) {
FilterFormat::load('test_format')
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
$decorator = 'Pink color';
$decorator_attributes = '[@style="color:pink;"]';
}
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->assertNotEmpty($drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media'));
$drupalmedia->click();
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
// Turn off caption, so we don't accidentally put our link in that text
// field instead of on the actual media.
$this->getBalloonButton('Toggle caption off')->click();
$assert_session->assertNoElementAfterWait('css', 'figure.drupal-media > figcaption');
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$this->getBalloonButton('Link media')->click();
$balloon = $this->assertVisibleBalloon('.ck-link-form');
$url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]');
$url_input->setValue('http://linking-embedded-media.com');
$balloon->pressButton('Insert');
$this->getBalloonButton('Link properties')->click();
$this->getBalloonButton($decorator)->click();
$this->getBalloonButton('Back')->click();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', '.drupal-media a'));
$this->assertVisibleBalloon('.ck-link-toolbar');
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes"));
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes/drupal-media"));
// Ensure that manual decorators upcast correctly.
$page->pressButton('Save');
$this->drupalGet($this->host->toUrl('edit-form'));
$this->assertNotEmpty($drupalmedia = $assert_session->waitForElementVisible('css', '.ck-content .ck-widget.drupal-media'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes"));
$this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes/drupal-media"));
// Finally, ensure that media can be unlinked.
$drupalmedia->click();
$this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]');
$this->getBalloonButton('Link media')->click();
$this->assertVisibleBalloon('.ck-link-toolbar');
$this->getBalloonButton('Unlink')->click();
$this->assertTrue($assert_session->waitForElementRemoved('css', '.drupal-media a'));
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//a'));
$this->assertNotEmpty($xpath->query('//drupal-media'));
}
}

View File

@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Database\Database;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\filter\Entity\FilterFormat;
// cspell:ignore drupalmediaediting
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media
* @group ckeditor5
* @group #slow
* @internal
*/
class MediaPreviewTest extends MediaTestBase {
/**
* Tests that failed media embed preview requests inform the end user.
*/
public function testErrorMessages(): void {
// This test currently frequently causes the SQLite database to lock, so
// skip the test on SQLite until the issue can be resolved.
// @todo https://www.drupal.org/project/drupal/issues/3273626
if (Database::getConnection()->driver() === 'sqlite') {
$this->markTestSkipped('Test frequently causes a locked database on SQLite');
}
// Assert that a request to the `media.filter.preview` route that does not
// result in a 200 response (due to server error or network error) is
// handled in the JavaScript by displaying the expected error message.
// @see core/modules/media/js/media_embed_ckeditor.theme.js
// @see js/ckeditor5_plugins/drupalMedia/src/drupalmediaediting.js
$this->container->get('state')->set('test_media_filter_controller_throw_error', TRUE);
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$assert_session->waitForElementVisible('css', '.ck-widget.drupal-media');
$this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000));
$assert_session->elementNotExists('css', '.ck-widget.drupal-media .media');
$this->assertNotEmpty($assert_session->waitForText('An error occurred while trying to preview the media. Save your work and reload this page.'));
// Now assert that the error doesn't appear when the override to force an
// error is removed.
$this->container->get('state')->set('test_media_filter_controller_throw_error', FALSE);
$this->getSession()->reload();
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
// There's a second kind of error message that comes from the back end
// that happens when the media uuid can't be converted to a media preview.
// In this case, the error will appear in a the themeable
// media-embed-error.html template. We have a hook altering the css
// classes to test the twig template is working properly and picking up our
// extra class.
// @see \Drupal\media\Plugin\Filter\MediaEmbed::renderMissingMediaIndicator()
// @see core/modules/media/templates/media-embed-error.html.twig
// @see media_test_embed_preprocess_media_embed_error()
$original_value = $this->host->body->value;
$this->host->body->value = str_replace($this->media->uuid(), 'invalid_uuid', $original_value);
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.drupal-media .this-error-message-is-themeable'));
// Test when using the starterkit_theme theme, an additional class is added
// to the error, which is supported by
// stable9/templates/content/media-embed-error.html.twig.
$this->assertTrue($this->container->get('theme_installer')->install(['starterkit_theme']));
$this->config('system.theme')
->set('default', 'starterkit_theme')
->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-widget.drupal-media .this-error-message-is-themeable'));
// Test that restoring a valid UUID results in the media embed preview
// displaying.
$this->host->body->value = $original_value;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$assert_session->elementNotExists('css', '.ck-widget.drupal-media .this-error-message-is-themeable');
}
/**
* The CKEditor Widget must load a preview generated using the default theme.
*/
public function testPreviewUsesDefaultThemeAndIsClientCacheable(): void {
// Allow the test user to view the admin theme.
$this->adminUser
->addRole($this->drupalCreateRole(['view the administration theme']))
->save();
// Configure a different default and admin theme, like on most Drupal sites.
$this->config('system.theme')
->set('default', 'stable9')
->set('admin', 'starterkit_theme')
->save();
// Assert that when looking at an embedded entity in the CKEditor Widget,
// the preview is generated using the default theme, not the admin theme.
// @see media_test_embed_entity_view_alter()
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]');
$this->assertSame('stable9', $element->getAttribute('data-media-embed-test-active-theme'));
// Assert that the first preview request transferred >500 B over the wire.
// Then toggle source mode on and off. This causes the CKEditor widget to be
// destroyed and then reconstructed. Assert that during this reconstruction,
// a second request is sent. This second request should have transferred 0
// bytes: the browser should have cached the response, thus resulting in a
// much better user experience.
$this->assertGreaterThan(500, $this->getLastPreviewRequestTransferSize());
$this->pressEditorButton('Source');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-source-editing-area'));
// CKEditor 5 is very smart: if no changes were made in the Source Editing
// Area, it will not rerender the contents. In this test, we
// want to verify that Media preview responses are cached on the client side
// so it is essential that rerendering occurs. To achieve this, we append a
// single space.
$source_text_area = $this->getSession()->getPage()->find('css', '[name="body[0][value]"] + .ck-editor textarea');
$source_text_area->setValue($source_text_area->getValue() . ' ');
$this->pressEditorButton('Source');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$this->assertSame(0, $this->getLastPreviewRequestTransferSize());
}
/**
* Tests preview route access.
*
* @param bool $media_embed_enabled
* Whether to test with media_embed filter enabled on the text format.
* @param bool $can_use_format
* Whether the logged in user is allowed to use the text format.
*
* @dataProvider previewAccessProvider
*/
public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format): void {
// Reconfigure the host entity's text format to suit our needs.
/** @var \Drupal\filter\FilterFormatInterface $format */
$format = FilterFormat::load($this->host->body->format);
$format->set('filters', [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => $media_embed_enabled],
]);
$format->save();
$permissions = [
'bypass node access',
];
if ($can_use_format) {
$permissions[] = $format->getPermissionName();
}
$this->drupalLogin($this->drupalCreateUser($permissions));
$this->drupalGet($this->host->toUrl('edit-form'));
$assert_session = $this->assertSession();
if ($can_use_format) {
$this->waitForEditor();
if ($media_embed_enabled) {
// The preview rendering, which in this test will use Starterkit theme's
// media.html.twig template, will fail without the CSRF token/header.
// @see ::testEmbeddedMediaPreviewWithCsrfToken()
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'article.media'));
}
else {
// If the filter isn't enabled, there won't be an error, but the
// preview shouldn't be rendered.
$assert_session->elementNotExists('css', 'article.media');
}
}
else {
$assert_session->pageTextContains('This field has been disabled because you do not have sufficient permissions to edit it.');
}
}
/**
* Data provider for ::testEmbedPreviewAccess.
*/
public static function previewAccessProvider() {
return [
'media_embed filter enabled' => [
TRUE,
TRUE,
],
'media_embed filter disabled' => [
FALSE,
TRUE,
],
'media_embed filter enabled, user not allowed to use text format' => [
TRUE,
FALSE,
],
];
}
/**
* Ensure media preview isn't clickable.
*/
public function testMediaPointerEvent(): void {
$entityViewDisplay = EntityViewDisplay::load('media.image.view_mode_1');
$thumbnail = $entityViewDisplay->getComponent('thumbnail');
$thumbnail['settings']['image_link'] = 'file';
$entityViewDisplay->setComponent('thumbnail', $thumbnail);
$entityViewDisplay->save();
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$url = $this->host->toUrl('edit-form');
$this->drupalGet($url);
$this->waitForEditor();
$assert_session->waitForLink('default alt');
$page->find('css', '.ck .drupal-media')->click();
// Assert that the media preview is not clickable by comparing the URL.
$this->assertEquals($url->toString(), $this->getUrl());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Entity\Entity\EntityViewMode;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\media\Entity\Media;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Base class for CKEditor 5 Media integration tests.
*
* @internal
*/
abstract class MediaTestBase extends WebDriverTestBase {
use CKEditor5TestTrait;
use MediaTypeCreationTrait;
use TestFileCreationTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* The sample Media entity to embed.
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* The second sample Media entity to embed used in one of the tests.
*
* @var \Drupal\media\MediaInterface
*/
protected $mediaFile;
/**
* A host entity with a body field to embed media in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'media',
'node',
'text',
'media_test_embed',
'media_library',
'ckeditor5_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'starterkit_theme';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
EntityViewMode::create([
'id' => 'media.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 1',
])->save();
EntityViewMode::create([
'id' => 'media.22222',
'targetEntityType' => 'media',
'status' => TRUE,
'enabled' => TRUE,
'label' => 'View Mode 2 has Numeric ID',
])->save();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p> <br> <strong> <em> <a href> <drupal-media data-entity-type data-entity-uuid data-align data-view-mode data-caption alt>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => [
'status' => TRUE,
'settings' => [
'default_view_mode' => 'view_mode_1',
'allowed_view_modes' => [
'view_mode_1' => 'view_mode_1',
'22222' => '22222',
],
'allowed_media_types' => [],
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
'link',
'bold',
'italic',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'media_media' => [
'allow_view_mode_override' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Note that media_install() grants 'view media' to all users by default.
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
// Create a sample media entity to be embedded.
$this->createMediaType('image', ['id' => 'image']);
File::create([
'uri' => $this->getTestFiles('image')[0]->uri,
])->save();
$this->media = Media::create([
'bundle' => 'image',
'name' => 'Screaming hairy armadillo',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->media->save();
$this->createMediaType('file', ['id' => 'file']);
File::create([
'uri' => $this->getTestFiles('text')[0]->uri,
])->save();
$this->mediaFile = Media::create([
'bundle' => 'file',
'name' => 'Information about screaming hairy armadillo',
'field_media_file' => [
[
'target_id' => 2,
],
],
]);
$this->mediaFile->save();
// Set created media types for each view mode.
EntityViewDisplay::create([
'id' => 'media.image.view_mode_1',
'targetEntityType' => 'media',
'status' => TRUE,
'bundle' => 'image',
'mode' => 'view_mode_1',
])->save();
EntityViewDisplay::create([
'id' => 'media.image.22222',
'targetEntityType' => 'media',
'status' => TRUE,
'bundle' => 'image',
'mode' => '22222',
])->save();
// Create a sample host entity to embed media in.
$this->drupalCreateContentType(['type' => 'blog']);
$this->host = $this->createNode([
'type' => 'blog',
'title' => 'Animals with strange names',
'body' => [
'value' => '<drupal-media data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '" data-caption="baz"></drupal-media>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Verifies value of an attribute on the downcast <drupal-media> element.
*
* Assumes CKEditor is in source mode.
*
* @param string $attribute
* The attribute to check.
* @param string|null $value
* Either a string value or if NULL, asserts that <drupal-media> element
* doesn't have the attribute.
*
* @internal
*/
protected function assertSourceAttributeSame(string $attribute, ?string $value): void {
$dom = $this->getEditorDataAsDom();
$drupal_media = (new \DOMXPath($dom))->query('//drupal-media');
$this->assertNotEmpty($drupal_media);
if ($value === NULL) {
$this->assertFalse($drupal_media[0]->hasAttribute($attribute));
}
else {
$this->assertSame($value, $drupal_media[0]->getAttribute($attribute));
}
}
/**
* Gets the transfer size of the last preview request.
*
* @return int
* The size of the bytes transferred.
*/
protected function getLastPreviewRequestTransferSize(): int {
$javascript = <<<JS
(function(){
return window.performance
.getEntries()
.filter(function (entry) {
return entry.initiatorType == 'fetch' && entry.name.indexOf('/media/test_format/preview') !== -1;
})
.pop()
.transferSize;
})()
JS;
return $this->getSession()->evaluateScript($javascript);
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore sourceediting
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @internal
*/
class SourceEditingEmptyElementTest extends SourceEditingTestBase {
/**
* Tests creating empty inline elements using Source Editing.
*
* @testWith ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", "<p>Before and after.</p>", "<p>Before and after.</p>", null]
* ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", "<p>Before &nbsp;and after.</p>", null, "<i>"]
* ["<p>Before <i class=\"fab fa-drupal\"></i> and after.</p>", null, null, "<i class>"]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before and after.</p>", "<p>Before and after.</p>", null]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before &nbsp;and after.</p>", null, "<span>"]
* ["<p>Before <span class=\"icon my-icon\"></span> and after.</p>", "<p>Before <span class=\"icon\"></span> and after.</p>", null, "<span class=\"icon\">"]
*/
public function testEmptyInlineElement(string $input, ?string $expected_output_when_restricted, ?string $expected_output_when_unrestricted, ?string $allowed_elements_string): void {
$this->host->body->value = $input;
$this->host->save();
// If no expected output is specified, it should be identical to the input.
if ($expected_output_when_restricted === NULL) {
$expected_output_when_restricted = $input;
}
if ($expected_output_when_unrestricted === NULL) {
$expected_output_when_unrestricted = $input;
}
$text_editor = Editor::load('test_format');
$text_format = FilterFormat::load('test_format');
if ($allowed_elements_string) {
// Allow creating additional HTML using SourceEditing.
$settings = $text_editor->getSettings();
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'][] = $allowed_elements_string;
$text_editor->setSettings($settings);
// Keep the allowed HTML tags in sync.
$allowed_elements = HTMLRestrictions::fromTextFormat($text_format);
$updated_allowed_tags = $allowed_elements->merge(HTMLRestrictions::fromString($allowed_elements_string));
$filter_html_config = $text_format->filters('filter_html')
->getConfiguration();
$filter_html_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString();
$text_format->setFilterConfig('filter_html', $filter_html_config);
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// If valid, save both.
$text_format->save();
$text_editor->save();
}
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_output_when_restricted, $this->getEditorDataAsHtmlString());
// Make the text format unrestricted: disable filter_html.
$text_format
->setFilterConfig('filter_html', ['status' => FALSE])
->save();
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// Test with a text format allowing arbitrary HTML.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_output_when_unrestricted, $this->getEditorDataAsHtmlString());
}
}

View File

@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\HTMLRestrictions;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore gramma sourceediting
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @internal
*/
class SourceEditingTest extends SourceEditingTestBase {
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::buildConfigurationForm
*/
public function testSourceEditingSettingsForm(): void {
$this->drupalLogin($this->drupalCreateUser(['administer filters']));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The Source Editing plugin settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<div data-foo>';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Immediately save the configuration. Intentionally do nothing that would
// trigger an AJAX rebuild.
$page->pressButton('Save configuration');
// Verify that the configuration was saved.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$page->clickLink('Source editing');
$this->assertNotNull($ghs_textarea = $assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
$ghs_string = '<div data-foo>';
$this->assertSame($ghs_string, $ghs_textarea->getValue());
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertStringContainsString($ghs_string, $allowed_html_field->getValue(), "$ghs_string not found in the allowed tags value of: {$allowed_html_field->getValue()}");
}
/**
* Tests allowing extra attributes on already supported tags using GHS.
*/
public function testAllowingExtraAttributes(): void {
$original_text_editor = Editor::load('test_format');
$original_text_format = FilterFormat::load('test_format');
$allowed_elements = HTMLRestrictions::fromTextFormat($original_text_format);
$filter_html_config = $original_text_format->filters('filter_html')
->getConfiguration();
foreach ($this->providerAllowingExtraAttributes() as $data) {
$text_editor = clone $original_text_editor;
$text_format = clone $original_text_format;
[$original_markup, $expected_markup, $allowed_elements_string] = $data;
// Allow creating additional HTML using SourceEditing.
$settings = $text_editor->getSettings();
if ($allowed_elements_string) {
$settings['plugins']['ckeditor5_sourceEditing']['allowed_tags'][] = $allowed_elements_string;
}
$text_editor->setSettings($settings);
$new_config = $filter_html_config;
if ($allowed_elements_string) {
// Keep the allowed HTML tags in sync.
$updated_allowed_tags = $allowed_elements->merge(HTMLRestrictions::fromString($allowed_elements_string));
$new_config['settings']['allowed_html'] = $updated_allowed_tags->toFilterHtmlAllowedTagsString();
}
$text_format->setFilterConfig('filter_html', $new_config);
// Verify the text format and editor are still a valid pair.
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
$text_editor,
$text_format
))
));
// If valid, save both.
$text_format->save();
$text_editor->save();
$this->doTestAllowingExtraAttributes($original_markup, $expected_markup, $allowed_elements_string);
}
}
/**
* Tests extra attributes with a specific data set.
*/
protected function doTestAllowingExtraAttributes(string $original_markup, string $expected_markup, string $allowed_elements_string): void {
$this->host->body->value = $original_markup;
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assertSame($expected_markup, $this->getEditorDataAsHtmlString());
}
/**
* Data provider for ::testAllowingExtraAttributes().
*
* @return array
* The test cases.
*/
protected function providerAllowingExtraAttributes(): array {
$general_test_case_markup = '<div class="llama" data-llama="🦙"><p data-llama="🦙">The <a href="https://example.com/pirate" class="button" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" class="use-ajax" data-grammar="adjective">irate</a>.</p></div>';
return [
'no extra attributes allowed' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'',
],
// Common case: any attribute that is not `style` or `class`.
'<a data-grammar="subject">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-grammar="subject">',
],
'<a data-grammar="adjective">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-grammar="adjective">',
],
'<a data-grammar>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-grammar>',
],
// Edge case: `class`.
'<a class="button">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a class="button">',
],
'<a class="use-ajax">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class="use-ajax">',
],
'<a class>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a class="button" href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class>',
],
// Edge case: $text-container wildcard with additional
// attribute.
'<$text-container data-llama>' => [
$general_test_case_markup,
'<div class="llama" data-llama="🦙"><p data-llama="🦙">The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<$text-container data-llama>',
],
// Edge case: $text-container wildcard with stricter attribute
// constrain.
'<$text-container class="not-llama">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<$text-container class="not-llama">',
],
// Edge case: wildcard attribute names:
// - prefix, f.e. `data-*`
// - infix, f.e. `*gramma*`
// - suffix, f.e. `*-grammar`
'<a data-*>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a data-*>',
],
'<a *gramma*>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a *gramma*>',
],
'<a *-grammar>' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate" data-grammar="adjective">irate</a>.</p></div>',
'<a *-grammar>',
],
// Edge case: concrete attribute with wildcard class value.
'<a class="use-*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate">pirate</a> is <a class="use-ajax" href="https://example.com/irate">irate</a>.</p></div>',
'<a class="use-*">',
],
// Edge case: concrete attribute with wildcard attribute value.
'<a data-grammar="sub*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-grammar="sub*">',
],
// Edge case: `data-*` with wildcard attribute value.
'<a data-*="sub*">' => [
$general_test_case_markup,
'<div class="llama"><p>The <a href="https://example.com/pirate" data-grammar="subject">pirate</a> is <a href="https://example.com/irate">irate</a>.</p></div>',
'<a data-*="sub*">',
],
// Edge case: `style`.
// @todo https://www.drupal.org/project/drupal/issues/3304832
// Edge case: `type` attribute on lists.
// @todo Remove in https://www.drupal.org/project/drupal/issues/3274635.
'no numberedList-related additions to the Source Editing configuration' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol><li>foo</li><li>bar</li></ol>',
'',
],
'<ol type>' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type>',
],
'<ol type="A">' => [
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A"><li>foo</li><li>bar</li></ol>',
'<ol type="A">',
],
'no bulletedList-related additions to the Source Editing configuration' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul><li>foo</li><li>bar</li></ul>',
'',
],
'<ul type>' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type>',
],
'<ul type="circle">' => [
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle"><li>foo</li><li>bar</li></ul>',
'<ul type="circle">',
],
];
}
}

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Symfony\Component\Validator\ConstraintViolationInterface;
// cspell:ignore sourceediting
/**
* Provides a base class for testing the source editing function.
*
* @internal
*/
abstract class SourceEditingTestBase extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* A host entity with a body field whose text to edit with CKEditor 5.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<div class> <p> <br> <a href> <ol> <ul> <li>',
],
],
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'sourceEditing',
'link',
'bulletedList',
'numberedList',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => ['<div class>'],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
// Create a sample host entity to test CKEditor 5.
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
}

View File

@ -0,0 +1,664 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
// cspell:ignore sourceediting
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style
* @group ckeditor5
* @internal
*/
class StyleTest extends CKEditor5TestBase {
use CKEditor5TestTrait;
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style::buildConfigurationForm
*/
public function testStyleSettingsForm(): void {
$this->drupalLogin($this->drupalCreateUser(['administer filters']));
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
$this->createNewTextFormat($page, $assert_session);
// The Style plugin settings form should not be present.
$assert_session->elementNotExists('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style"]');
$this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-style'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-style', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// No validation error upon enabling the Style plugin.
$this->assertNoRealtimeValidationErrors();
$assert_session->pageTextContains('No styles configured');
// Still no validation error when configuring other functionality first.
$this->triggerKeyUp('.ckeditor5-toolbar-item-undo', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
$this->assertNoRealtimeValidationErrors();
// The Style plugin settings form should now be present and should have no
// styles configured.
$page->clickLink('Style');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]'));
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'p.foo.bar | Foobar paragraph';
allowedTags.dispatchEvent(new Event('input'));
JS;
$this->getSession()->executeScript($javascript);
// Immediately save the configuration. Intentionally do nothing that would
// trigger an AJAX rebuild.
$page->pressButton('Save configuration');
$assert_session->pageTextContains('Added text format');
// Verify that the configuration was saved.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$page->clickLink('Style');
$this->assertNotNull($styles_textarea = $assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]'));
$this->assertSame("p.foo.bar|Foobar paragraph\n", $styles_textarea->getValue());
$assert_session->pageTextContains('One style configured');
$allowed_html_field = $assert_session->fieldExists('filters[filter_html][settings][allowed_html]');
$this->assertStringContainsString('<p class="foo bar">', $allowed_html_field->getValue());
// Attempt to use an unsupported HTML5 tag.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 's.redacted|Redacted';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
// The CKEditor 5 module should refuse to specify styles on tags that cannot
// (yet) be created.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\FundamentalCompatibilityConstraintValidator::checkAllHtmlTagsAreCreatable()
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("The Style plugin needs another plugin to create <s>, for it to be able to create the following attributes: <s class="redacted">. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// The entire vertical tab for "Style" settings should be marked up as the
// cause of the error, which means the "Styles" text area in there is marked
// too.
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"][aria-invalid="true"]');
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"] textarea[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"][aria-invalid="true"]');
// Attempt to save anyway: the warning should become an error.
$page->pressButton('Save configuration');
$assert_session->pageTextNotContains('Added text format');
$assert_session->elementExists('css', '[aria-label="Error message"]:contains("The Style plugin needs another plugin to create <s>, for it to be able to create the following attributes: <s class="redacted">. Enable a plugin that supports creating this tag. If none exists, you can configure the Source Editing plugin to support it.")');
// Now, attempt to use a supported non-HTML5 tag.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\StyleSensibleElementConstraintValidator
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'drupal-media.sensational|Sensational media';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
// The CKEditor 5 module should refuse to allow styles on non-HTML5 tags.
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("A style can only be specified for an HTML 5 tag. <drupal-media> is not an HTML5 tag.")');
// The vertical tab for "Style" settings should not be marked up as the
// cause of the error, but only the "Styles" text area in the vertical tab.
$assert_session->elementNotExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"][aria-invalid="true"]');
$assert_session->elementExists('css', '.vertical-tabs__pane[data-ckeditor5-plugin-id="ckeditor5_style"] textarea[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"][aria-invalid="true"]');
// Test configuration overlaps across plugins.
$this->drupalGet('admin/config/content/formats/manage/ckeditor5');
$this->assertNotEmpty($assert_session->elementExists('css', '.ckeditor5-toolbar-item-sourceEditing'));
$this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
$assert_session->assertWaitOnAjaxRequest();
// The Source Editing plugin settings form should now be present and should
// have no allowed tags configured.
$page->clickLink('Source editing');
$this->assertNotNull($assert_session->waitForElementVisible('css', '[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]'));
// Make `<aside class>` creatable.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-sourceediting-allowed-tags"]');
allowedTags.value = '<aside class>';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
$assert_session->assertWaitOnAjaxRequest();
// Create a style with `aside` and a class name.
$javascript = <<<JS
const allowedTags = document.querySelector('[data-drupal-selector="edit-editor-settings-plugins-ckeditor5-style-styles"]');
allowedTags.value = 'aside.error|Aside';
allowedTags.dispatchEvent(new Event('change'));
JS;
$this->getSession()->executeScript($javascript);
$assert_session->assertWaitOnAjaxRequest();
// The CKEditor 5 module should refuse to create configuration overlaps
// across plugins.
// @see \Drupal\ckeditor5\Plugin\Validation\Constraint\StyleSensibleElementConstraintValidator::findStyleConflictingPluginLabel()
$assert_session->waitForElement('css', '[role=alert][data-drupal-message-type="error"]:contains("A style must only specify classes not supported by other plugins.")');
}
/**
* Tests Style functionality: setting a class, expected style choices.
*/
public function testStyleFunctionality(): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<p class="highlighted interesting"> <br> <a href class="reliable"> <blockquote class="famous"> <h2 class="red-heading"> <ul class="items"> <ol class="steps"> <li> <table class="data-analysis"> <tr> <td rowspan colspan> <th rowspan colspan> <thead> <tbody> <tfoot> <caption class="caution"> <div class="deep-dive">',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => [
'heading',
'link',
'blockQuote',
'style',
'bulletedList',
'numberedList',
'insertTable',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_heading' => [
'enabled_headings' => [
'heading2',
],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
'ckeditor5_sourceEditing' => [
'allowed_tags' => [
'<div>',
],
],
'ckeditor5_style' => [
'styles' => [
[
'label' => 'Highlighted & interesting',
'element' => '<p class="highlighted interesting">',
],
[
'label' => 'Red heading',
'element' => '<h2 class="red-heading">',
],
[
'label' => 'Reliable source',
'element' => '<a class="reliable">',
],
[
'label' => 'Famous',
'element' => '<blockquote class="famous">',
],
[
'label' => 'Items',
'element' => '<ul class="items">',
],
[
'label' => 'Steps',
'element' => '<ol class="steps">',
],
[
'label' => 'Data analysis',
'element' => '<table class="data-analysis">',
],
[
'label' => 'Truly deep dive',
'element' => '<div class="deep-dive">',
],
[
'label' => 'Caution caption',
'element' => '<caption class="caution">',
],
],
],
],
],
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Create a sample entity to test CKEditor 5.
$node = $this->createNode([
'type' => 'page',
'title' => 'A selection of the history of Drupal',
'body' => [
'value' => '<h2>Upgrades</h2><p class="history">Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p> — <a href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div><ul><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table><caption>Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>',
'format' => 'test_format',
],
]);
$node->save();
// Observe.
$this->drupalLogin($this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]));
// Set a taller window size to ensure all possible style choices are in view
// because otherwise Mink's getText() will return the empty string for those
// out of view, despite the HTML showing that text.
$this->getSession()->resizeWindow(1024, 1000);
$this->drupalGet($node->toUrl('edit-form'));
$this->waitForEditor();
// Select the <h2>, assert that no style is active currently.
$this->selectTextInsideElement('h2');
$assert_session = $this->assertSession();
$style_dropdown = $assert_session->elementExists('css', '.ck-style-dropdown');
$this->assertSame('Styles', $style_dropdown->getText());
// Click the dropdown, check the available styles.
$style_dropdown->click();
$buttons = $style_dropdown->findAll('css', '.ck-dropdown__panel button');
$this->assertCount(9, $buttons);
$this->assertSame('Highlighted & interesting', $buttons[0]->find('css', '.ck-button__label')->getText());
$this->assertSame('Red heading', $buttons[1]->find('css', '.ck-button__label')->getText());
$this->assertSame('Famous', $buttons[2]->find('css', '.ck-button__label')->getText());
$this->assertSame('Items', $buttons[3]->find('css', '.ck-button__label')->getText());
$this->assertSame('Steps', $buttons[4]->find('css', '.ck-button__label')->getText());
$this->assertSame('Data analysis', $buttons[5]->find('css', '.ck-button__label')->getText());
$this->assertSame('Truly deep dive', $buttons[6]->find('css', '.ck-button__label')->getText());
$this->assertSame('Caution caption', $buttons[7]->find('css', '.ck-button__label')->getText());
// CKEditor's Style plugin first shows all block styles.
for ($i = 0; $i <= 7; $i++) {
$style_group = $buttons[$i]->getParent()->getParent();
$this->assertTrue($style_group->hasClass('ck-style-panel__style-group'));
$this->assertSame('Block styles', $style_group->find('css', 'label')->getText());
}
// And then all text styles.
for ($i = 8; $i <= 8; $i++) {
$style_group = $buttons[$i]->getParent()->getParent();
$this->assertTrue($style_group->hasClass('ck-style-panel__style-group'));
$this->assertSame('Text styles', $style_group->find('css', 'label')->getText());
}
$this->assertSame('Reliable source', $buttons[8]->find('css', '.ck-button__label')->getText());
$this->assertSame('true', $buttons[0]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[1]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
// Apply the "Red heading" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main h2:not(.red-heading)');
$buttons[1]->click();
$assert_session->elementExists('css', '.ck-editor__main h2.red-heading');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-on'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Red heading', $style_dropdown->getText());
// Select the first paragraph and observe changes in:
// - styles dropdown label
// - button states
$this->selectTextInsideElement('p');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Close the dropdown.
$style_dropdown->click();
// Select the blockquote and observe changes in:
// - styles dropdown label
// - button states
$this->selectTextInsideElement('blockquote');
$this->assertSame('Famous', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-on'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
// TRICKY: the blockquote contains a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[2]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Close the dropdown.
$style_dropdown->click();
// Select the <ul> and check the available styles
$this->selectTextInsideElement('ul');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[3]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ul> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Items" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main ul:not(.items)');
$buttons[3]->click();
$assert_session->elementExists('css', '.ck-editor__main ul.items');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-on'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Items', $style_dropdown->getText());
// Select the <ol> and check the available styles
$this->selectTextInsideElement('ol');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[4]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ol> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Steps" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main ol:not(.steps)');
$buttons[4]->click();
$assert_session->elementExists('css', '.ck-editor__main ol.steps');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Steps', $style_dropdown->getText());
// Select the table and check the available styles
$this->selectTextInsideElement('table td');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the table cell can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[5]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Data analysis" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main table:not(.data-analysis)');
$buttons[5]->click();
$assert_session->elementExists('css', '.ck-editor__main table.data-analysis');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Data analysis', $style_dropdown->getText());
// Select the link, assert that no style is active currently.
$this->selectTextInsideElement('a');
$this->assertSame('Styles', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the link is inside a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[8]->hasAttribute('aria-disabled'));
// Apply the "Reliable source" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main a:not(.reliable)');
$buttons[8]->click();
$assert_session->elementExists('css', '.ck-editor__main a.reliable');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-on'));
$this->assertSame('Reliable source', $style_dropdown->getText());
// Because we cannot select the <div> directly (it's not a visible element),
// select the <ol> AGAIN and check the available styles — because we should
// be able to change the containing <div>'s style through here. Note that we
// already activated the "Steps" style previously.
$this->selectTextInsideElement('ol');
$this->assertSame('Steps', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
// TRICKY: the contents of the list item can be converted to a paragraph.
$this->assertFalse($buttons[0]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[4]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[5]->getAttribute('aria-disabled'));
// TRICKY: the <ol> is wrapped in a <div>, so the "Truly deep dive" <div>
// style is available!
$this->assertFalse($buttons[6]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[7]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Truly deep dive" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main div:not(.deep-dive)');
$buttons[6]->click();
$assert_session->elementExists('css', '.ck-editor__main div.deep-dive');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-on'));
$this->assertTrue($buttons[5]->hasClass('ck-off'));
$this->assertTrue($buttons[6]->hasClass('ck-on'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Multiple styles', $style_dropdown->getText());
// Select the table caption, assert that no style is active currently.
$this->selectTextInsideElement('figure.table > figcaption');
$this->assertSame('Data analysis', $style_dropdown->getText());
$style_dropdown->click();
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
// TRICKY: the caption is inside the table, so the "Data analysis" style is
// also active.
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-off'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('true', $buttons[0]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[1]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[2]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[3]->getAttribute('aria-disabled'));
$this->assertSame('true', $buttons[4]->getAttribute('aria-disabled'));
// TRICKY: the caption is inside the table, so the "Data analysis" style is
// also active.
$this->assertFalse($buttons[5]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[6]->getAttribute('aria-disabled'));
$this->assertFalse($buttons[7]->hasAttribute('aria-disabled'));
$this->assertSame('true', $buttons[8]->getAttribute('aria-disabled'));
// Apply the "Caution caption" style and verify it has the expected effect.
$assert_session->elementExists('css', '.ck-editor__main figure.table > figcaption:not(.caution)');
$buttons[7]->click();
$assert_session->elementExists('css', '.ck-editor__main figure.table > figcaption.caution');
$this->assertTrue($buttons[0]->hasClass('ck-off'));
$this->assertTrue($buttons[1]->hasClass('ck-off'));
$this->assertTrue($buttons[2]->hasClass('ck-off'));
$this->assertTrue($buttons[3]->hasClass('ck-off'));
$this->assertTrue($buttons[4]->hasClass('ck-off'));
$this->assertTrue($buttons[5]->hasClass('ck-on'));
$this->assertTrue($buttons[6]->hasClass('ck-off'));
$this->assertTrue($buttons[7]->hasClass('ck-on'));
$this->assertTrue($buttons[8]->hasClass('ck-off'));
$this->assertSame('Multiple styles', $style_dropdown->getText());
// The resulting markup should be identical to the starting markup, with
// seven changes:
// 1. the `red-heading` class has been added to the `<h2>`
// 2. the `history` class has been removed from the `<p>`, because CKEditor
// 5 has not been configured for this: if a Style had configured for it,
// it would have been retained.
// 3. the `items` class has been added to the `<ul>`
// 4. the `steps` class has been added to the `<ol>`
// 5. the `data-analysis` class has been added to the `<table>`
// 6. the `reliable` class has been added to the `<a>`
// 7. The `deep-dive` class has been added to the `<div>`
// 8. The `caution` class has been added to the `<caption>`
$this->assertSame('<h2 class="red-heading">Upgrades</h2><p>Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p>— <a class="reliable" href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div class="deep-dive"><ul class="items"><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol class="steps"><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table class="table data-analysis"><caption class="caution">Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>', $this->getEditorDataAsHtmlString());
}
}

View File

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\FunctionalJavascript;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* For testing the table plugin.
*
* @group ckeditor5
* @internal
*/
class TableTest extends WebDriverTestBase {
use CKEditor5TestTrait;
/**
* A host entity with a body field to embed images in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* Text added to captions.
*
* @var string
*/
protected $captionText = 'some caption';
/**
* Text added to table cells.
*
* @var string
*/
protected $tableCellText = 'table cell';
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'node',
'text',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page']);
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => '<br> <p> <table> <tr> <td rowspan colspan> <th rowspan colspan> <thead> <tbody> <tfoot> <caption>',
],
],
],
])->save();
Editor::create([
'editor' => 'ckeditor5',
'format' => 'test_format',
'image_upload' => [
'status' => FALSE,
],
'settings' => [
'toolbar' => [
'items' => [
'insertTable',
'sourceEditing',
],
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
],
],
])->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
// Create a sample host entity.
$this->host = $this->createNode([
'type' => 'page',
'title' => 'Animals with strange names',
'body' => [
'value' => '<p>some content that will likely change</p>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]));
}
/**
* Confirms tables convert to the expected markup.
*/
public function testTableConversion(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
// This is CKEditor 5's default table markup, but uses elements that are
// not allowed by the text format.
$this->host->body->value = '<figure class="table"><table><tbody><tr><td>table cell</td></tr></tbody></table> <figcaption>some caption</figcaption></figure>';
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->captionText = 'some caption';
$this->tableCellText = 'table cell';
$table_container = $assert_session->waitForElementVisible('css', 'figure.table');
$this->assertNotNull($table_container);
$caption = $page->find('css', 'figure.table > figcaption');
$this->assertEquals($this->captionText, $caption->getText());
$table = $page->find('css', 'figure.table > table');
$this->assertEquals($this->tableCellText, $table->getText());
$this->assertTableStructureInEditorData();
$this->assertTableStructureInRenderedPage();
}
/**
* Tests creating a table with caption in the UI.
*/
public function testTableCaptionUi(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
// Add a table via the editor buttons.
$table_button = $page->find('css', '.ck-dropdown button');
$table_button->click();
// Add a single table cell.
$grid_button = $assert_session->waitForElementVisible('css', '.ck-insert-table-dropdown-grid-box[data-row="1"][data-column="1"]');
$grid_button->click();
// Confirm the table has been added and no caption is present.
$this->assertNotNull($table_figure = $assert_session->waitForElementVisible('css', 'figure.table'));
$assert_session->elementNotExists('css', 'figure.table > figcaption');
// Enable captions and update caption content.
$caption_button = $this->getBalloonButton('Toggle caption on');
$caption_button->click();
$caption = $assert_session->waitForElementVisible('css', 'figure.table > figcaption');
$this->assertEmpty($caption->getText());
$caption->setValue($this->captionText);
$this->assertEquals($this->captionText, $caption->getText());
// Update table cell content.
$table_cell = $assert_session->waitForElement('css', '.ck-editor__nested-editable .ck-table-bogus-paragraph');
$this->assertNotEmpty($table_cell);
$table_cell->click();
$table_cell->setValue($this->tableCellText);
$table_cell = $page->find('css', 'figure.table > table > tbody > tr > td');
$this->assertEquals($this->tableCellText, $table_cell->getText());
$this->assertTableStructureInEditorData();
// Disable caption, confirm the caption is no longer present.
$table_figure->click();
$caption_off_button = $this->getBalloonButton('Toggle caption off');
$caption_off_button->click();
$assert_session->assertNoElementAfterWait('css', 'figure.table > figcaption');
// Re-enable caption and confirm the value was retained.
$table_figure->click();
$caption_on_button = $this->getBalloonButton('Toggle caption on');
$caption_on_button->click();
$caption = $assert_session->waitForElementVisible('css', 'figure.table > figcaption');
$this->assertEquals($this->captionText, $caption->getText());
$this->assertTableStructureInRenderedPage();
}
/**
* Confirms the structure of the table within the editor data.
*/
public function assertTableStructureInEditorData(): void {
$xpath = new \DOMXPath($this->getEditorDataAsDom());
$this->assertEmpty($xpath->query('//figure'), 'There should be no figure tag in editor data');
$this->assertNotEmpty($xpath->query('//table/caption'), 'A caption should be the immediate child of <table>');
$this->assertEquals($this->captionText, (string) $xpath->query('//table/caption')[0]->nodeValue, "The caption should say {$this->captionText}");
$this->assertNotEmpty($xpath->query('//table/tbody/tr/td'), 'There is an expected table structure.');
$this->assertEquals($this->tableCellText, (string) $xpath->query('//table/tbody/tr/td')[0]->nodeValue, "The table cell should say {$this->tableCellText}");
}
/**
* Confirms the saved page has the expected table structure.
*/
public function assertTableStructureInRenderedPage(): void {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
$page->pressButton('Save');
$assert_session->waitForText('has been updated');
$assert_session->pageTextContains($this->tableCellText);
$assert_session->pageTextContains($this->captionText);
$assert_session->elementNotExists('css', 'figure');
$this->assertNotNull($table_cell = $page->find('css', 'table > tbody > tr > td'), 'Table on rendered page has expected structure');
$this->assertEquals($this->tableCellText, $table_cell->getText(), 'Table on rendered page has expected content');
$this->assertNotNull($table_caption = $page->find('css', 'table > caption '), 'Table caption is in expected structure.');
$this->assertEquals($this->captionText, $table_caption->getText(), 'Table caption has expected text');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Test the ckeditor5-stylesheets theme config property.
*
* @group ckeditor5
*/
class CKEditor5StylesheetsTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'ckeditor5',
'editor',
'filter',
];
/**
* Tests loading of theme's CKEditor 5 stylesheets defined in the .info file.
*
* @param string $theme
* The machine name of the theme.
* @param array $expected
* The expected CKEditor 5 CSS paths from the theme.
*
* @dataProvider externalStylesheetsProvider
*/
public function testExternalStylesheets($theme, $expected): void {
\Drupal::service('theme_installer')->install([$theme]);
$this->config('system.theme')->set('default', $theme)->save();
$this->assertSame($expected, _ckeditor5_theme_css($theme));
}
/**
* Provides test cases for external stylesheets.
*
* @return array
* An array of test cases.
*/
public static function externalStylesheetsProvider() {
return [
'Install theme which has an absolute external CSS URL' => [
'test_ckeditor_stylesheets_external',
['https://fonts.googleapis.com/css?family=Open+Sans'],
],
'Install theme which has an external protocol-relative CSS URL' => [
'test_ckeditor_stylesheets_protocol_relative',
['//fonts.googleapis.com/css?family=Open+Sans'],
],
'Install theme which has a relative CSS URL' => [
'test_ckeditor_stylesheets_relative',
['/core/modules/system/tests/themes/test_ckeditor_stylesheets_relative/css/yokotsoko.css'],
],
'Install theme which has a Drupal root CSS URL' => [
'test_ckeditor_stylesheets_drupal_root',
['/core/modules/system/tests/themes/test_ckeditor_stylesheets_drupal_root/css/yokotsoko.css'],
],
];
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\EditorInterface;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Defines a trait for testing CKEditor 5 validity.
*/
trait CKEditor5ValidationTestTrait {
/**
* Decorator for CKEditor5::validatePair() that returns an assertable array.
*
* @param \Drupal\editor\EditorInterface $text_editor
* The paired text editor to validate.
* @param \Drupal\filter\FilterFormatInterface $text_format
* The paired text format to validate.
* @param bool $all_compatibility_problems
* Only fundamental compatibility violations are returned unless TRUE.
*
* @return array
* An array with property paths as keys and violation messages as values.
*
* @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::validatePair
*/
private function validatePairToViolationsArray(EditorInterface $text_editor, FilterFormatInterface $text_format, bool $all_compatibility_problems): array {
$violations = CKEditor5::validatePair($text_editor, $text_format, $all_compatibility_problems);
return self::violationsToArray($violations);
}
/**
* Transforms a constraint violation list object to an assertable array.
*
* @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations
* Validation constraint violations.
*
* @return array
* An array with property paths as keys and violation messages as values.
*/
private static function violationsToArray(ConstraintViolationListInterface $violations): array {
$actual_violations = [];
foreach ($violations as $violation) {
if (!isset($actual_violations[$violation->getPropertyPath()])) {
$actual_violations[$violation->getPropertyPath()] = (string) $violation->getMessage();
}
else {
// Transform value from string to array.
if (is_string($actual_violations[$violation->getPropertyPath()])) {
$actual_violations[$violation->getPropertyPath()] = (array) $actual_violations[$violation->getPropertyPath()];
}
// And append.
$actual_violations[$violation->getPropertyPath()][] = (string) $violation->getMessage();
}
}
return $actual_violations;
}
}

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel\ConfigAction;
use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Recipe\InvalidConfigException;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\editor\Entity\Editor;
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\KernelTests\KernelTestBase;
/**
* @covers \Drupal\ckeditor5\Plugin\ConfigAction\AddItemToToolbar
* @group ckeditor5
* @group Recipe
*/
class AddItemToToolbarConfigActionTest extends KernelTestBase {
use RecipeTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'editor',
'filter',
'filter_test',
'user',
];
/**
* {@inheritdoc}
*/
protected static $configSchemaCheckerExclusions = [
// This test must be allowed to save invalid config, we can confirm that
// any invalid stuff is validated by the config actions system.
'editor.editor.filter_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('filter_test');
$editor = Editor::create([
'editor' => 'ckeditor5',
'format' => 'filter_test',
'image_upload' => ['status' => FALSE],
]);
$editor->save();
/** @var array{toolbar: array{items: array<int, string>}} $settings */
$settings = Editor::load('filter_test')?->getSettings();
$this->assertSame(['heading', 'bold', 'italic'], $settings['toolbar']['items']);
}
/**
* @param string|array<string, mixed> $action
* The value to pass to the config action.
* @param string[] $expected_toolbar_items
* The items which should be in the editor toolbar, in the expected order.
*
* @testWith ["sourceEditing", ["heading", "bold", "italic", "sourceEditing"]]
* [{"item_name": "sourceEditing"}, ["heading", "bold", "italic", "sourceEditing"]]
* [{"item_name": "sourceEditing", "position": 1}, ["heading", "sourceEditing", "bold", "italic"]]
* [{"item_name": "sourceEditing", "position": 1, "replace": true}, ["heading", "sourceEditing", "italic"]]
* [{"item_name": "bold"}, ["heading", "bold", "italic"]]
* [{"item_name": "bold", "allow_duplicate": true}, ["heading", "bold", "italic", "bold"]]
*/
public function testAddItemToToolbar(string|array $action, array $expected_toolbar_items): void {
$recipe = $this->createRecipe([
'name' => 'CKEditor 5 toolbar item test',
'config' => [
'actions' => [
'editor.editor.filter_test' => [
'addItemToToolbar' => $action,
],
],
],
]);
RecipeRunner::processRecipe($recipe);
/** @var array{toolbar: array{items: string[]}, plugins: array<string, array<mixed>>} $settings */
$settings = Editor::load('filter_test')?->getSettings();
$this->assertSame($expected_toolbar_items, $settings['toolbar']['items']);
// The plugin's default settings should have been added.
if (in_array('sourceEditing', $expected_toolbar_items, TRUE)) {
$this->assertSame([], $settings['plugins']['ckeditor5_sourceEditing']['allowed_tags']);
}
}
/**
* Tests that adding non-existent toolbar item to CKEditor triggers an error.
*/
public function testAddNonExistentItem(): void {
$recipe = $this->createRecipe([
'name' => 'Add an invalid toolbar item',
'config' => [
'actions' => [
'editor.editor.filter_test' => [
'addItemToToolbar' => 'bogus_item',
],
],
],
]);
$this->expectException(InvalidConfigException::class);
$this->expectExceptionMessage("There were validation errors in editor.editor.filter_test:\n- settings.toolbar.items.3: The provided toolbar item <em class=\"placeholder\">bogus_item</em> is not valid.");
RecipeRunner::processRecipe($recipe);
}
/**
* Tests that the `addItemToToolbar` config action requires CKEditor 5.
*/
public function testActionRequiresCKEditor5(): void {
$this->enableModules(['editor_test']);
Editor::load('filter_test')?->setEditor('unicorn')->setSettings([])->save();
$recipe = <<<YAML
name: Not a CKEditor
config:
actions:
editor.editor.filter_test:
addItemToToolbar: strikethrough
YAML;
$this->expectException(ConfigActionException::class);
$this->expectExceptionMessage('The editor:addItemToToolbar config action only works with editors that use CKEditor 5.');
RecipeRunner::processRecipe($this->createRecipe($recipe));
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests configurable plugins.
*
* @group ckeditor5
* @internal
*/
class ConfigurablePluginTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
// These modules must be installed for ckeditor5_config_schema_info_alter()
// to work, which in turn is necessary for the plugin definition validation
// logic.
// @see \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition::validateDrupalAspects()
'filter',
'editor',
];
/**
* The manager for "CKEditor 5 plugin" plugins.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $manager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->manager = $this->container->get('plugin.manager.ckeditor5.plugin');
}
/**
* Tests default settings for configurable CKEditor 5 plugins.
*/
public function testDefaults(): void {
$all_definitions = $this->manager->getDefinitions();
$configurable_definitions = array_filter($all_definitions, function (CKEditor5PluginDefinition $definition): bool {
return $definition->isConfigurable();
});
$default_plugin_settings = [];
foreach (array_keys($configurable_definitions) as $plugin_name) {
$default_plugin_settings[$plugin_name] = $this->manager->getPlugin($plugin_name, NULL)->defaultConfiguration();
}
$expected_default_plugin_settings = [
'ckeditor5_heading' => [
'enabled_headings' => [
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
],
],
'ckeditor5_style' => [
'styles' => [],
],
'ckeditor5_sourceEditing' => [
'allowed_tags' => [],
],
'ckeditor5_codeBlock' => [
'languages' => [
['language' => 'plaintext', 'label' => 'Plain text'],
['language' => 'c', 'label' => 'C'],
['language' => 'cs', 'label' => 'C#'],
['language' => 'cpp', 'label' => 'C++'],
['language' => 'css', 'label' => 'CSS'],
['language' => 'diff', 'label' => 'Diff'],
['language' => 'html', 'label' => 'HTML'],
['language' => 'java', 'label' => 'Java'],
['language' => 'javascript', 'label' => 'JavaScript'],
['language' => 'php', 'label' => 'PHP'],
['language' => 'python', 'label' => 'Python'],
['language' => 'ruby', 'label' => 'Ruby'],
['language' => 'typescript', 'label' => 'TypeScript'],
['language' => 'xml', 'label' => 'XML'],
],
],
'ckeditor5_list' => [
'properties' => [
'reversed' => TRUE,
'startIndex' => TRUE,
],
'multiBlock' => TRUE,
],
'ckeditor5_alignment' => [
'enabled_alignments' => [
0 => 'left',
1 => 'center',
2 => 'right',
3 => 'justify',
],
],
'ckeditor5_image' => [],
'ckeditor5_imageResize' => [
'allow_resize' => TRUE,
],
'ckeditor5_language' => [
'language_list' => 'un',
],
];
$this->assertSame($expected_default_plugin_settings, $default_plugin_settings);
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\TestTools\Random;
use Symfony\Component\Yaml\Yaml;
/**
* Tests language resolving for CKEditor 5.
*
* @group ckeditor5
* @internal
*/
class LanguageTest extends KernelTestBase {
/**
* The CKEditor 5 plugin.
*
* @var \Drupal\ckeditor5\Plugin\Editor\CKEditor5
*/
protected $ckeditor5;
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'ckeditor5',
'editor',
'filter',
'language',
'locale',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->ckeditor5 = $this->container->get('plugin.manager.editor')->createInstance('ckeditor5');
FilterFormat::create(
Yaml::parseFile('core/profiles/standard/config/install/filter.format.basic_html.yml')
)->save();
Editor::create([
'format' => 'basic_html',
'editor' => 'ckeditor5',
'image_upload' => [
'status' => FALSE,
],
])->save();
$this->installConfig(['language']);
}
/**
* Ensure that languages are resolved correctly.
*
* @param string $drupal_langcode
* The language code in Drupal.
* @param string $cke5_langcode
* The language code in CKEditor 5.
* @param bool $is_missing_mapping
* Whether this mapping is expected to be missing from language.mappings.
*
* @dataProvider provider
*/
public function test(string $drupal_langcode, string $cke5_langcode, bool $is_missing_mapping = FALSE): void {
$editor = Editor::load('basic_html');
ConfigurableLanguage::createFromLangcode($drupal_langcode)->save();
$this->config('system.site')->set('default_langcode', $drupal_langcode)->save();
if ($is_missing_mapping) {
// CKEditor 5's UI language falls back to English, until the language
// mapping is expanded.
$settings = $this->ckeditor5->getJSSettings($editor);
$this->assertSame('en', $settings['language']['ui']);
// Expand the language mapping.
$config = $this->config('language.mappings');
$mapping = $config->get('map');
$mapping += [$cke5_langcode => $drupal_langcode];
$config->set('map', $mapping)->save();
}
$settings = $this->ckeditor5->getJSSettings($editor);
$this->assertSame($cke5_langcode, $settings['language']['ui']);
}
/**
* Provides a list of language code pairs.
*
* @return string[][]
* An array of language code pairs.
*/
public static function provider(): array {
$random_langcode = Random::machineName();
return [
'Language code transformed from browser mappings' => [
'drupal_langcode' => 'pt-pt',
'cke5_langcode' => 'pt',
],
'Language code transformed from browser mappings 2' => [
'drupal_langcode' => 'zh-hans',
'cke5_langcode' => 'zh-cn',
],
'Language code both in Drupal and CKEditor' => [
'drupal_langcode' => 'fi',
'cke5_langcode' => 'fi',
],
'Language code not in Drupal but in CKEditor 5 requires new language.mappings entry' => [
'drupal_langcode' => $random_langcode,
'cke5_langcode' => 'de-ch',
'is_missing_mapping' => TRUE,
],
];
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Kernel;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig
* @group ckeditor5
* @internal
*/
class WildcardHtmlSupportTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor5',
'filter',
'editor',
];
/**
* The manager for "CKEditor 5 plugin" plugins.
*
* @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface
*/
protected $manager;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->manager = $this->container->get('plugin.manager.ckeditor5.plugin');
}
/**
* @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::getDynamicPluginConfig
* @dataProvider providerGhsConfiguration
*/
public function testGhsConfiguration(string $filter_html_allowed, array $source_editing_tags, array $expected_ghs_configuration, ?array $additional_toolbar_items = []): void {
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_html' => [
'status' => TRUE,
'settings' => [
'allowed_html' => $filter_html_allowed,
],
],
],
])->save();
$editor_config = [
'editor' => 'ckeditor5',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'items' => array_merge(['sourceEditing'], $additional_toolbar_items),
],
'plugins' => [
'ckeditor5_sourceEditing' => [
'allowed_tags' => $source_editing_tags,
],
],
],
'image_upload' => [
'status' => FALSE,
],
];
if (in_array('alignment', $additional_toolbar_items, TRUE)) {
$editor_config['settings']['plugins']['ckeditor5_alignment'] = [
'enabled_alignments' => ['left', 'center', 'right', 'justify'],
];
}
$editor = Editor::create($editor_config);
$editor->save();
$this->assertSame([], array_map(
function (ConstraintViolationInterface $v) {
return (string) $v->getMessage();
},
iterator_to_array(CKEditor5::validatePair(
Editor::load('test_format'),
FilterFormat::load('test_format')
))
));
$config = $this->manager->getCKEditor5PluginConfig($editor);
$ghs_configuration = $config['config']['htmlSupport']['allow'];
// The first two entries in the GHS configuration are from the
// `ckeditor5_globalAttributeDir` and `ckeditor5_globalAttributeLang`
// plugins. They are out of scope for this test, so omit them.
$ghs_configuration = array_slice($ghs_configuration, 2);
$this->assertEquals($expected_ghs_configuration, $ghs_configuration);
}
/**
* Provides test cases for CKEditor 5 General HTML Support (GHS) configuration.
*/
public static function providerGhsConfiguration(): array {
return [
'empty source editing' => [
'<p> <br>',
[],
[],
],
'without wildcard' => [
'<p> <br> <a href> <blockquote> <div data-llama>',
['<div data-llama>'],
[
[
'name' => 'div',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
['link', 'blockQuote'],
],
'<$text-container> minimal configuration' => [
'<p data-llama> <br>',
['<$text-container data-llama>'],
[
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
],
'<$text-container> from multiple plugins' => [
'<p data-llama class="text-align-left text-align-center text-align-right text-align-justify"> <br>',
['<$text-container data-llama>'],
[
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
'classes' => [
'regexp' => [
'pattern' => '/^(text-align-left|text-align-center|text-align-right|text-align-justify)$/',
],
],
],
],
['alignment'],
],
'<$text-container> with attribute from multiple plugins' => [
'<p data-llama class> <br>',
['<$text-container data-llama>', '<p class>'],
[
[
'name' => 'p',
'classes' => TRUE,
],
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
'classes' => [
'regexp' => [
'pattern' => '/^(text-align-left|text-align-center|text-align-right|text-align-justify)$/',
],
],
],
],
['alignment'],
],
'<$text-container> realistic configuration' => [
'<p data-llama> <br> <a href> <blockquote> <div data-llama> <mark> <abbr title>',
['<$text-container data-llama>', '<div>', '<mark>', '<abbr title>'],
[
[
'name' => 'div',
],
[
'name' => 'mark',
],
[
'name' => 'abbr',
'attributes' => [
[
'key' => 'title',
'value' => TRUE,
],
],
],
[
'name' => 'p',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
[
'name' => 'div',
'attributes' => [
[
'key' => 'data-llama',
'value' => TRUE,
],
],
],
],
['link', 'blockQuote'],
],
];
}
}

View File

@ -0,0 +1,163 @@
// cspell:ignore drupalhtmlbuilder
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
// eslint-disable-next-line import/no-extraneous-dependencies
const { JSDOM } = require('jsdom');
// Nightwatch doesn't support ES modules. This workaround loads the class
// directly here.
// @todo remove this after https://www.drupal.org/project/drupal/issues/3247647
// has been resolved.
// eslint-disable-next-line no-eval
const DrupalHtmlBuilder = eval(
`(${fs
.readFileSync(
path.resolve(
__dirname,
'../../../../js/ckeditor5_plugins/drupalHtmlEngine/src/drupalhtmlbuilder.js',
),
)
.toString()})`.replace('export default', ''),
);
const { document, Node } = new JSDOM(`<!DOCTYPE html>`).window;
module.exports = {
'@tags': ['ckeditor5'],
'@unitTest': true,
'should return empty string when empty DocumentFragment is passed':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
drupalHtmlBuilder.appendNode(document.createDocumentFragment());
assert.equal(drupalHtmlBuilder.build(), '');
},
'should create text from single text node': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const text = 'foo bar';
const fragment = document.createDocumentFragment();
const textNode = document.createTextNode(text);
fragment.appendChild(textNode);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), text);
},
'should return correct HTML from fragment with paragraph': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const paragraph = document.createElement('p');
paragraph.textContent = 'foo bar';
fragment.appendChild(paragraph);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<p>foo bar</p>');
},
'should return correct HTML from fragment with multiple child nodes':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const text = document.createTextNode('foo bar');
const paragraph = document.createElement('p');
const div = document.createElement('div');
paragraph.textContent = 'foo';
div.textContent = 'bar';
fragment.appendChild(text);
fragment.appendChild(paragraph);
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'foo bar<p>foo</p><div>bar</div>',
);
},
'should return correct HTML scripts and styles': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const script = document.createElement('script');
script.textContent = `let x = 10;
let y = 5;
if (y < x) {
console.log('is smaller')
}`;
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.appendChild(
document.createTextNode(':root .sections > h2 { background: red}'),
);
fragment.appendChild(style);
fragment.appendChild(document.createTextNode('\n'));
fragment.appendChild(script);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
`<style type="text/css">:root .sections > h2 { background: red}</style>
<script>let x = 10;
let y = 5;
if (y < x) {
console.log('is smaller')
}</script>`,
);
},
'should return correct HTML from fragment with comment': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
const comment = document.createComment('bar');
div.textContent = 'bar';
fragment.appendChild(div);
fragment.appendChild(comment);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<div>bar</div><!--bar-->');
},
'should return correct HTML from fragment with attributes': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
div.setAttribute('id', 'foo');
div.classList.add('bar');
div.textContent = 'baz';
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'<div id="foo" class="bar">baz</div>',
);
},
'should return correct HTML from fragment with self closing tag':
function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const hr = document.createElement('hr');
fragment.appendChild(hr);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(drupalHtmlBuilder.build(), '<hr>');
},
'attribute values should be escaped': function () {
const drupalHtmlBuilder = new DrupalHtmlBuilder();
const fragment = document.createDocumentFragment();
const div = document.createElement('div');
div.setAttribute('data-caption', 'Kittens & llamas are <em>cute</em>');
div.textContent = 'foo';
fragment.appendChild(div);
drupalHtmlBuilder.appendNode(fragment);
assert.equal(
drupalHtmlBuilder.build(),
'<div data-caption="Kittens &amp; llamas are &lt;em&gt;cute&lt;/em&gt;">foo</div>',
);
},
};

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Traits;
use Behat\Mink\Element\NodeElement;
use Drupal\Component\Utility\Html;
// cspell:ignore downcasted
/**
* Provides methods to test CKEditor 5.
*
* This trait is meant to be used only by functional JavaScript test classes.
*/
trait CKEditor5TestTrait {
/**
* Gets CKEditor 5 instance data as a PHP DOMDocument.
*
* @return \DOMDocument
* The result of parsing CKEditor 5's data into a PHP DOMDocument.
*/
protected function getEditorDataAsDom(): \DOMDocument {
return Html::load($this->getEditorDataAsHtmlString());
}
/**
* Gets CKEditor 5 instance data as a HTML string.
*
* @return string
* The result of retrieving CKEditor 5's data.
*
* @see https://ckeditor.com/docs/ckeditor5/latest/api/module_editor-classic_classiceditor-ClassicEditor.html#function-getData
*/
protected function getEditorDataAsHtmlString(): string {
// We cannot trust on CKEditor updating the textarea every time model
// changes. Therefore, the most reliable way to get downcasted data is to
// use the CKEditor API.
$javascript = <<<JS
(function(){
return Drupal.CKEditor5Instances.get(Drupal.CKEditor5Instances.keys().next().value).getData();
})();
JS;
return $this->getSession()->evaluateScript($javascript);
}
/**
* Waits for CKEditor to initialize.
*/
protected function waitForEditor(): void {
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
}
/**
* Clicks a CKEditor button.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function pressEditorButton($name): void {
$this->getEditorButton($name)->click();
}
/**
* Waits for a CKEditor button and returns it when available and visible.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*
* @return \Behat\Mink\Element\NodeElement|null
* The page element node if found, NULL if not.
*/
protected function getEditorButton($name) {
$button = $this->assertSession()->waitForElementVisible('xpath', "//button[span[text()='$name']]");
$this->assertNotEmpty($button);
return $button;
}
/**
* Asserts a CKEditor button is disabled.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function assertEditorButtonDisabled($name): void {
$button = $this->getEditorButton($name);
$this->assertTrue($button->hasAttribute('aria-disabled'));
$this->assertTrue($button->hasClass('ck-disabled'));
}
/**
* Asserts a CKEditor button is enabled.
*
* @param string $name
* The name of the button, such as `drupallink`, `source`, etc.
*/
protected function assertEditorButtonEnabled($name): void {
$button = $this->getEditorButton($name);
$this->assertFalse($button->hasAttribute('aria-disabled'));
$this->assertFalse($button->hasClass('ck-disabled'));
}
/**
* Asserts a particular balloon is visible.
*
* @param string $balloon_content_selector
* A CSS selector.
*
* @return \Behat\Mink\Element\NodeElement
* The asserted balloon.
*/
protected function assertVisibleBalloon(string $balloon_content_selector): NodeElement {
$this->assertSession()->elementExists('css', '.ck-balloon-panel_visible');
$selector = ".ck-balloon-panel_visible .ck-balloon-rotator__content > .ck$balloon_content_selector";
$this->assertSession()->elementExists('css', $selector);
return $this->getSession()->getPage()->find('css', $selector);
}
/**
* Gets a button from the currently visible balloon.
*
* @param string $name
* The label of the button to find.
*
* @return \Behat\Mink\Element\NodeElement
* The requested button.
*/
protected function getBalloonButton(string $name): NodeElement {
$button = $this->getSession()->getPage()
->find('css', '.ck-balloon-panel_visible .ck-balloon-rotator__content')
->find('xpath', "//button[span[text()='$name']]");
$this->assertNotEmpty($button);
return $button;
}
/**
* Selects text inside an element.
*
* @param string $selector
* A CSS selector for the element which contents should be selected.
*/
protected function selectTextInsideElement(string $selector): void {
$javascript = <<<JS
(function() {
const el = document.querySelector(".ck-editor__main $selector");
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
})();
JS;
$this->getSession()->evaluateScript($javascript);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Traits;
/**
* Provides methods to test protected/private methods in unit tests.
*
* @internal
*/
trait PrivateMethodUnitTestTrait {
/**
* Gets a protected/private method to test.
*
* @param string $fqcn
* A fully qualified classname.
* @param string $name
* The method name.
*
* @return \ReflectionMethod
* The accessible method.
*/
protected static function getMethod(string $fqcn, string $name): \ReflectionMethod {
$class = new \ReflectionClass($fqcn);
$method = $class->getMethod($name);
return $method;
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Traits;
use Drupal\Core\Session\AccountInterface;
/**
* Synchronizes the child site's CSRF token seed back to the test runner.
*
* For the test to be able to generate valid CSRF tokens, it needs access to the
* CSRF token seed in the child site (i.e. tested site). This requires reading
* the CSRF token seed from the session that gets created in the child site
* after logging in, and then setting it in the test runner's container.
* Otherwise, the test runner would generate its own CSRF token seed and would
* hence generate CSRF tokens that are not valid for the session in the child
* site.
*
* @see \Drupal\Core\Access\CsrfTokenGenerator::get()
*
* @internal
*/
trait SynchronizeCsrfTokenSeedTrait {
/**
* {@inheritdoc}
*/
protected function drupalLogin(AccountInterface $account) {
parent::drupalLogin($account);
$session_data = $this->container->get('session_handler.write_safe')->read($this->getSession()->getCookie($this->getSessionName()));
$csrf_token_seed = unserialize(explode('_sf2_meta|', $session_data)[1])['s'];
$this->container->get('session_manager.metadata_bag')->setCsrfTokenSeed($csrf_token_seed);
}
/**
* {@inheritdoc}
*/
protected function rebuildContainer() {
parent::rebuildContainer();
// Ensure that the CSRF token seed is reset on container rebuild.
if ($this->loggedInUser) {
$current_user = $this->loggedInUser;
$this->drupalLogout();
$this->drupalLogin($current_user);
}
}
/**
* {@inheritdoc}
*/
protected function drupalLogout() {
parent::drupalLogout();
$this->container->get('session_manager.metadata_bag')->stampNew();
}
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Alignment;
use Drupal\editor\EditorInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\Yaml\Yaml;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Alignment
* @group ckeditor5
* @internal
*/
class AlignmentPluginTest extends UnitTestCase {
/**
* Provides a list of configs to test.
*/
public static function providerGetDynamicPluginConfig(): array {
return [
'All alignments' => [
Alignment::DEFAULT_CONFIGURATION,
[
'alignment' => [
'options' => [
[
'name' => 'left',
'className' => 'text-align-left',
],
[
'name' => 'center',
'className' => 'text-align-center',
],
[
'name' => 'right',
'className' => 'text-align-right',
],
[
'name' => 'justify',
'className' => 'text-align-justify',
],
],
],
],
],
'No alignments allowed' => [
[
'enabled_alignments' => [],
],
[
'alignment' => [
'options' => [],
],
],
],
'Left only' => [
[
'enabled_alignments' => [
'left',
],
],
[
'alignment' => [
'options' => [
[
'name' => 'left',
'className' => 'text-align-left',
],
],
],
],
],
'Left and justify only' => [
[
'enabled_alignments' => [
'left',
'justify',
],
],
[
'alignment' => [
'options' => [
[
'name' => 'left',
'className' => 'text-align-left',
],
[
'name' => 'justify',
'className' => 'text-align-justify',
],
],
],
],
],
];
}
/**
* @covers ::getDynamicPluginConfig
* @dataProvider providerGetDynamicPluginConfig
*/
public function testGetDynamicPluginConfig(array $configuration, array $expected_dynamic_config): void {
// Read the CKEditor 5 plugin's static configuration from YAML.
$ckeditor5_plugin_definitions = Yaml::parseFile(__DIR__ . '/../../../ckeditor5.ckeditor5.yml');
$static_plugin_config = $ckeditor5_plugin_definitions['ckeditor5_alignment']['ckeditor5']['config'];
$plugin = new Alignment($configuration, 'ckeditor5_alignment', NULL);
$dynamic_plugin_config = $plugin->getDynamicPluginConfig($static_plugin_config, $this->prophesize(EditorInterface::class)
->reveal());
$this->assertSame($expected_dynamic_config, $dynamic_plugin_config);
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Controller\CKEditor5ImageController;
use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface;
use Drupal\Core\Entity\EntityConstraintViolationList;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\EntityTypeRepositoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\editor\EditorInterface;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\file\Upload\FileUploadHandlerInterface;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Tests CKEditor5ImageController.
*
* @group ckeditor5
* @coversDefaultClass \Drupal\ckeditor5\Controller\CKEditor5ImageController
*/
final class CKEditor5ImageControllerTest extends UnitTestCase {
/**
* Tests that upload fails correctly when the file is too large.
*/
public function testInvalidFile(): void {
$file_system = $this->prophesize(FileSystemInterface::class);
$file_system->move(Argument::any())->shouldNotBeCalled();
$directory = 'public://';
$file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)->willReturn(TRUE);
$file_system->getDestinationFilename(Argument::cetera())->willReturn('/tmp/foo.txt');
$lock = $this->prophesize(LockBackendInterface::class);
$lock->acquire(Argument::any())->willReturn(TRUE);
$container = $this->prophesize(ContainerInterface::class);
$file_storage = $this->prophesize(EntityStorageInterface::class);
$file = $this->prophesize(FileInterface::class);
$violations = $this->prophesize(EntityConstraintViolationList::class);
$violations->count()->willReturn(0);
$file->validate()->willReturn($violations->reveal());
$file_storage->create(Argument::any())->willReturn($file->reveal());
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
$entity_type_manager->getStorage('file')->willReturn($file_storage->reveal());
$container->get('entity_type.manager')->willReturn($entity_type_manager->reveal());
$entity_type_repository = $this->prophesize(EntityTypeRepositoryInterface::class);
$entity_type_repository->getEntityTypeFromClass(File::class)->willReturn('file');
$container->get('entity_type.repository')->willReturn($entity_type_repository->reveal());
\Drupal::setContainer($container->reveal());
$controller = new CKEditor5ImageController(
$file_system->reveal(),
$this->prophesize(FileUploadHandlerInterface::class)->reveal(),
$lock->reveal(),
$this->prophesize(CKEditor5PluginManagerInterface::class)->reveal(),
);
// We can't use vfsstream here because of how Symfony request works.
$file_uri = tempnam(sys_get_temp_dir(), 'tmp');
$fp = fopen($file_uri, 'w');
fwrite($fp, 'foo');
fclose($fp);
$request = Request::create('/', files: [
'upload' => [
'name' => 'foo.txt',
'type' => 'text/plain',
'size' => 42,
'tmp_name' => $file_uri,
'error' => \UPLOAD_ERR_FORM_SIZE,
],
]);
$editor = $this->prophesize(EditorInterface::class);
$request->attributes->set('editor', $editor->reveal());
$this->expectException(HttpException::class);
$this->expectExceptionMessage('The file "foo.txt" exceeds the upload limit defined in your form.');
$controller->upload($request);
}
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\Editor\CKEditor5;
use Drupal\Tests\ckeditor5\Traits\PrivateMethodUnitTestTrait;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\Editor\CKEditor5
* @group ckeditor5
* @internal
*/
class CKEditor5Test extends UnitTestCase {
use PrivateMethodUnitTestTrait;
/**
* Simulated CKEditor5::buildConfigurationForm() form structure.
*
* @var array
*/
protected const SIMULATED_FORM_STRUCTURE = [
'toolbar' => [
'available' => [],
'items' => [],
],
'available_items_description' => [],
'active_items_description' => [],
'plugin_settings' => [],
'plugins' => [
'providerA_plugin1' => [],
'providerB_plugin2' => [
'foo' => [],
'bar' => [],
],
],
];
/**
* @covers \Drupal\ckeditor5\Plugin\Editor\CKEditor5::mapViolationPropertyPathsToFormNames
* @dataProvider providerPathsToFormNames
*/
public function testPathsToFormNames(string $property_path, string $expected_form_item_name, bool $expect_exception = FALSE): void {
$mapMethod = self::getMethod(CKEditor5::class, 'mapViolationPropertyPathsToFormNames');
if ($expect_exception) {
$this->expectExceptionMessage('assert($shifted === \'settings\')');
}
$form_item_name = $mapMethod->invokeArgs(NULL, [$property_path, static::SIMULATED_FORM_STRUCTURE]);
if (!$expect_exception) {
$this->assertSame($expected_form_item_name, $form_item_name);
}
}
/**
* Data provider for testing mapViolationPropertyPathsToFormNames.
*
* @return array[]
* An array with the property path and expected form item name.
*/
public static function providerPathsToFormNames(): array {
return [
'validation error targeting toolbar items' => [
'settings.toolbar.items',
'settings][toolbar][items',
],
'validation error targeting a specific toolbar item' => [
'settings.toolbar.items.6',
'settings][toolbar][items',
],
'validation error targeting a simple plugin form' => [
'settings.plugins.providerA_plugin1',
'settings][plugins][providerA_plugin1',
],
'validation error targeting a simple plugin form, with deep config schema detail' => [
'settings.plugins.providerA_plugin1.foo.bar.baz',
'settings][plugins][providerA_plugin1',
],
'validation error targeting a complex plugin form' => [
'settings.plugins.providerB_plugin2',
'settings][plugins][providerB_plugin2',
],
'validation error targeting a complex plugin form, with deep config schema detail' => [
'settings.plugins.providerB_plugin2.foo.bar.baz',
'settings][plugins][providerB_plugin2][foo',
],
'unrealistic example one — should trigger exception' => [
'bad.bad.worst',
'I DO NOT EXIST',
TRUE,
],
'unrealistic example two — should trigger exception' => [
'one.two.three.four',
'one][two][three][four',
TRUE,
],
];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading;
use Drupal\editor\EditorInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\Yaml\Yaml;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Language
* @group ckeditor5
* @internal
*/
class HeadingPluginTest extends UnitTestCase {
/**
* Provides a list of configs to test.
*/
public static function providerGetDynamicPluginConfig(): array {
// Prepare headings matching ckeditor5.ckeditor5.yml to also protect
// against unexpected changes to the YAML file given the YAML file is used
// to generate the dynamic plugin configuration.
$paragraph = [
'model' => 'paragraph',
'title' => 'Paragraph',
'class' => 'ck-heading_paragraph',
];
$headings = [];
foreach (range(2, 6) as $number) {
$headings[$number] = [
'model' => 'heading' . $number,
'view' => 'h' . $number,
'title' => 'Heading ' . $number,
'class' => 'ck-heading_heading' . $number,
];
}
return [
'All headings' => [
Heading::DEFAULT_CONFIGURATION,
[
'heading' => [
'options' => [
$paragraph,
$headings[2],
$headings[3],
$headings[4],
$headings[5],
$headings[6],
],
],
],
],
'Only required headings' => [
[
'enabled_headings' => [],
],
[
'heading' => [
'options' => [
$paragraph,
],
],
],
],
'Heading 2 only' => [
[
'enabled_headings' => [
'heading2',
],
],
[
'heading' => [
'options' => [
$paragraph,
$headings[2],
],
],
],
],
'Heading 2 and 3 only' => [
[
'enabled_headings' => [
'heading2',
'heading3',
],
],
[
'heading' => [
'options' => [
$paragraph,
$headings[2],
$headings[3],
],
],
],
],
];
}
/**
* @covers ::getDynamicPluginConfig
* @dataProvider providerGetDynamicPluginConfig
*/
public function testGetDynamicPluginConfig(array $configuration, array $expected_dynamic_config): void {
// Read the CKEditor 5 plugin's static configuration from YAML.
$ckeditor5_plugin_definitions = Yaml::parseFile(__DIR__ . '/../../../ckeditor5.ckeditor5.yml');
$static_plugin_config = $ckeditor5_plugin_definitions['ckeditor5_heading']['ckeditor5']['config'];
$plugin = new Heading($configuration, 'ckeditor5_heading', NULL);
$dynamic_plugin_config = $plugin->getDynamicPluginConfig($static_plugin_config, $this->prophesize(EditorInterface::class)
->reveal());
$this->assertSame($expected_dynamic_config, $dynamic_plugin_config);
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Language;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
use Drupal\Core\Language\Language as LanguageLanguage;
use Drupal\Core\Language\LanguageManager;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\editor\EditorInterface;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Language
* @group ckeditor5
* @internal
*/
class LanguagePluginTest extends UnitTestCase {
/**
* Provides a list of configs to test.
*/
public static function providerGetDynamicPluginConfig(): array {
$united_nations_expected_output = [
'language' => [
'textPartLanguage' => [
[
'title' => 'Arabic',
'languageCode' => 'ar',
'textDirection' => 'rtl',
],
[
'title' => 'Chinese, Simplified',
'languageCode' => 'zh-hans',
],
[
'title' => 'English',
'languageCode' => 'en',
],
[
'title' => 'French',
'languageCode' => 'fr',
],
[
'title' => 'Russian',
'languageCode' => 'ru',
],
[
'title' => 'Spanish',
'languageCode' => 'es',
],
],
],
];
return [
'un' => [
['language_list' => 'un'],
$united_nations_expected_output,
],
'site_configured' => [
['language_list' => 'site_configured'],
[
'language' => [
'textPartLanguage' => [
[
'title' => 'Arabic',
'languageCode' => 'ar',
'textDirection' => 'rtl',
],
[
'title' => 'German',
'languageCode' => 'de',
],
],
],
],
],
'all' => [
['language_list' => 'all'],
[
'language' => [
'textPartLanguage' => static::buildExpectedDynamicConfig(LanguageManager::getStandardLanguageList()),
],
],
],
'default configuration' => [
[],
$united_nations_expected_output,
],
];
}
/**
* Builds the expected dynamic configuration output given a language list.
*
* @param array $language_list
* The languages list from the language manager.
*
* @return array
* The expected output of the dynamic plugin configuration.
*/
protected static function buildExpectedDynamicConfig(array $language_list): array {
$expected_language_config = [];
foreach ($language_list as $language_code => $language_list_item) {
$item = [
'title' => $language_list_item[0],
'languageCode' => $language_code,
];
if (isset($language_list_item[2])) {
$item['textDirection'] = $language_list_item[2];
}
$expected_language_config[$item['title']] = $item;
}
ksort($expected_language_config);
return array_values($expected_language_config);
}
/**
* @covers ::getDynamicPluginConfig
* @dataProvider providerGetDynamicPluginConfig
*/
public function testGetDynamicPluginConfig(array $configuration, array $expected_dynamic_config): void {
$route_provider = $this->prophesize(RouteProviderInterface::class);
$language_manager = $this->prophesize(LanguageManagerInterface::class);
$language_manager->getLanguages()->willReturn([
new LanguageLanguage([
'id' => 'de',
'name' => 'German',
]),
new LanguageLanguage([
'id' => 'ar',
'name' => 'Arabic',
'direction' => 'rtl',
]),
]);
$plugin = new Language($configuration, 'ckeditor5_language', new CKEditor5PluginDefinition(['id' => 'IRRELEVANT-FOR-A-UNIT-TEST']), $language_manager->reveal(), $route_provider->reveal());
$dynamic_config = $plugin->getDynamicPluginConfig([], $this->prophesize(EditorInterface::class)
->reveal());
$this->assertSame($expected_dynamic_config, $dynamic_config);
}
}

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ckeditor5\Unit;
use Drupal\ckeditor5\Plugin\CKEditor5Plugin\ListPlugin;
use Drupal\editor\Entity\Editor;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\Yaml\Yaml;
/**
* @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\ListPlugin
* @group ckeditor5
* @internal
*/
class ListPluginTest extends UnitTestCase {
/**
* Provides a list of configs to test.
*/
public static function providerGetDynamicPluginConfig(): array {
return [
'startIndex is false' => [
[
'properties' => [
'reversed' => TRUE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
[
'list' => [
'properties' => [
'reversed' => TRUE,
'startIndex' => FALSE,
'styles' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
'reversed is false' => [
[
'properties' => [
'reversed' => FALSE,
'startIndex' => TRUE,
],
'multiBlock' => TRUE,
],
[
'list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => TRUE,
'styles' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
'both disabled' => [
[
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
],
'multiBlock' => TRUE,
],
[
'list' => [
'properties' => [
'reversed' => FALSE,
'startIndex' => FALSE,
'styles' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
'both enabled' => [
[
'properties' => [
'reversed' => TRUE,
'startIndex' => TRUE,
],
'multiBlock' => TRUE,
],
[
'list' => [
'properties' => [
'reversed' => TRUE,
'startIndex' => TRUE,
'styles' => FALSE,
],
'multiBlock' => TRUE,
],
],
],
];
}
/**
* @covers ::getDynamicPluginConfig
*
* @dataProvider providerGetDynamicPluginConfig
*/
public function testGetDynamicPluginConfig(array $configuration, array $expected_dynamic_config): void {
// Read the CKEditor 5 plugin's static configuration from YAML.
$ckeditor5_plugin_definitions = Yaml::parseFile(__DIR__ . '/../../../ckeditor5.ckeditor5.yml');
$static_plugin_config = $ckeditor5_plugin_definitions['ckeditor5_list']['ckeditor5']['config'];
$plugin = new ListPlugin($configuration, 'ckeditor5_list', NULL);
$dynamic_plugin_config = $plugin->getDynamicPluginConfig($static_plugin_config, $this->prophesize(Editor::class)
->reveal());
$this->assertSame($expected_dynamic_config, $dynamic_plugin_config);
}
}

Some files were not shown because too many files have changed in this diff Show More