Initial Drupal 11 with DDEV setup
This commit is contained in:
617
web/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php
Normal file
617
web/core/tests/Drupal/BuildTests/Command/GenerateThemeTest.php
Normal file
@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Command;
|
||||
|
||||
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
|
||||
use Drupal\Core\Command\GenerateTheme;
|
||||
use Drupal\Core\Serialization\Yaml;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Console\Tester\Constraint\CommandIsSuccessful;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the generate-theme commands.
|
||||
*
|
||||
* @requires extension pdo_sqlite
|
||||
*
|
||||
* @group Command
|
||||
*/
|
||||
class GenerateThemeTest extends QuickStartTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $php;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = $php_executable_finder->find();
|
||||
$this->copyCodebase();
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
|
||||
chdir($this->getWorkingPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates PHP process to generate a theme from core's starterkit theme.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The PHP process
|
||||
*/
|
||||
private function generateThemeFromStarterkit($env = NULL) : Process {
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
];
|
||||
$process = new Process($install_command, NULL, $env);
|
||||
$process->setTimeout(60);
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the theme exists. Returns the parsed *.info.yml file.
|
||||
*
|
||||
* @param string $theme_path_relative
|
||||
* The core-relative path to the theme.
|
||||
*
|
||||
* @return array
|
||||
* The parsed *.info.yml file.
|
||||
*/
|
||||
private function assertThemeExists(string $theme_path_relative): array {
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . "/$theme_path_relative";
|
||||
$theme_name = basename($theme_path_relative);
|
||||
$info_yml_filename = "$theme_name.info.yml";
|
||||
$this->assertFileExists($theme_path_absolute . '/' . $info_yml_filename);
|
||||
$info = Yaml::decode(file_get_contents($theme_path_absolute . '/' . $info_yml_filename));
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command.
|
||||
*/
|
||||
public function test(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a concrete version
|
||||
// number, to simulate using a tagged core release.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
$theme_path_relative = 'themes/test_custom_theme';
|
||||
$info = $this->assertThemeExists($theme_path_relative);
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:9.4.0', $info['generator']);
|
||||
|
||||
// Confirm readme is rewritten.
|
||||
$readme_file = $this->getWorkspaceDirectory() . "/$theme_path_relative/README.md";
|
||||
$this->assertSame('"Test custom starterkit theme" theme, generated from starterkit_theme. Additional information on generating themes can be found in the [Starterkit documentation](https://www.drupal.org/docs/core-modules-and-themes/core-themes/starterkit-theme).', file_get_contents($readme_file));
|
||||
|
||||
// Ensure that the generated theme can be installed.
|
||||
$this->installQuickStart('minimal');
|
||||
$this->formLogin($this->adminUsername, $this->adminPassword);
|
||||
$this->visit('/admin/appearance');
|
||||
$this->getMink()->assertSession()->pageTextContains('Test custom starterkit');
|
||||
$this->getMink()->assertSession()->pageTextContains('Custom theme generated from a starterkit theme');
|
||||
$this->getMink()->getSession()->getPage()->clickLink('Install "Test custom starterkit theme" theme');
|
||||
$this->getMink()->assertSession()->pageTextContains('The "Test custom starterkit theme" theme has been installed.');
|
||||
|
||||
// Ensure that a new theme cannot be generated when the destination
|
||||
// directory already exists.
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . "/$theme_path_relative";
|
||||
$this->assertFileExists($theme_path_absolute . '/test_custom_theme.theme');
|
||||
unlink($theme_path_absolute . '/test_custom_theme.theme');
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme could not be generated because the destination directory', $process->getErrorOutput());
|
||||
$this->assertStringContainsString($theme_path_relative, $process->getErrorOutput());
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($theme_path_absolute . '/test_custom_theme.theme');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests generating a theme from another Starterkit enabled theme.
|
||||
*/
|
||||
public function testGeneratingFromAnotherTheme(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a concrete version
|
||||
// number, to simulate using a tagged core release.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$exit_code = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $exit_code);
|
||||
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/themes/test_custom_theme/test_custom_theme.starterkit.yml', <<<YAML
|
||||
delete: []
|
||||
no_edit: []
|
||||
no_rename: []
|
||||
info:
|
||||
version: 1.0.0
|
||||
YAML
|
||||
);
|
||||
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'generated_from_another_theme',
|
||||
'--name="Generated from another theme"',
|
||||
'--description="Custom theme generated from a theme other than starterkit_theme"',
|
||||
'--starterkit=test_custom_theme',
|
||||
];
|
||||
$process = new Process($install_command);
|
||||
$exit_code = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/generated_from_another_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $exit_code);
|
||||
|
||||
// Confirm new .theme file.
|
||||
$dot_theme_file = $this->getWorkspaceDirectory() . '/themes/generated_from_another_theme/generated_from_another_theme.theme';
|
||||
$this->assertStringContainsString('function generated_from_another_theme_preprocess_image_widget(array &$variables): void {', file_get_contents($dot_theme_file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a dev snapshot of Drupal core.
|
||||
*/
|
||||
public function testDevSnapshot(): void {
|
||||
// Do not rely on \Drupal::VERSION: change the version to a development
|
||||
// snapshot version number, to simulate using a branch snapshot of core.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '9.4.0-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
$theme_path_relative = 'themes/test_custom_theme';
|
||||
$info = $this->assertThemeExists($theme_path_relative);
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertMatchesRegularExpression('/^starterkit_theme\:9.4.0-dev#[0-9a-f]+$/', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a release version number.
|
||||
*/
|
||||
public function testContribStarterkit(): void {
|
||||
// Change the version to a concrete version number, to simulate using a
|
||||
// contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['version'] = '1.20';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme generated successfully to themes/test_custom_theme', trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:1.20', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a dev version number.
|
||||
*/
|
||||
public function testContribStarterkitDevSnapshot(): void {
|
||||
// Change the version to a development snapshot version number, to simulate
|
||||
// using a contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['core_version_requirement'] = '*';
|
||||
$info['version'] = '7.x-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
// Avoid the core git commit from being considered the source theme's: move
|
||||
// it out of core.
|
||||
Process::fromShellCommandline('mv core/themes/starterkit_theme themes/', $this->getWorkspaceDirectory())->run();
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString("The source theme starterkit_theme has a development version number (7.x-dev). Because it is not a git checkout, a specific commit could not be identified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:7.x-dev#unknown-commit', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme with a dev version without git.
|
||||
*/
|
||||
public function testContribStarterkitDevSnapshotWithGitNotInstalled(): void {
|
||||
// Change the version to a development snapshot version number, to simulate
|
||||
// using a contrib theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['core_version_requirement'] = '*';
|
||||
$info['version'] = '7.x-dev';
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
// Avoid the core git commit from being considered the source theme's: move
|
||||
// it out of core.
|
||||
Process::fromShellCommandline('mv core/themes/starterkit_theme themes/', $this->getWorkspaceDirectory())->run();
|
||||
|
||||
// Confirm that 'git' is available.
|
||||
$output = [];
|
||||
exec('git --help', $output, $status);
|
||||
$this->assertEquals(0, $status);
|
||||
// Modify our $PATH so that it begins with a path that contains an
|
||||
// executable script named 'git' that always exits with 127, as if git were
|
||||
// not found. Note that we run our tests using process isolation, so we do
|
||||
// not need to restore the PATH when we are done.
|
||||
$unavailableGitPath = $this->getWorkspaceDirectory() . '/bin';
|
||||
putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
|
||||
mkdir($unavailableGitPath);
|
||||
$bash = <<<SH
|
||||
#!/bin/bash
|
||||
exit 127
|
||||
|
||||
SH;
|
||||
file_put_contents($unavailableGitPath . '/git', $bash);
|
||||
chmod($unavailableGitPath . '/git', 0755);
|
||||
// Confirm that 'git' is no longer available.
|
||||
$process = new Process(['git', '--help']);
|
||||
$process->run();
|
||||
$this->assertEquals(127, $process->getExitCode(), 'Fake git used by process.');
|
||||
|
||||
$process = $this->generateThemeFromStarterkit([
|
||||
'PATH' => getenv('PATH'),
|
||||
'COLUMNS' => 80,
|
||||
]);
|
||||
$result = $process->run();
|
||||
$this->assertEquals("[ERROR] The source theme starterkit_theme has a development version number \n (7.x-dev). Determining a specific commit is not possible because git is\n not installed. Either install git or use a tagged release to generate a\n theme.", trim($process->getErrorOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(1, $result);
|
||||
$this->assertFileDoesNotExist($this->getWorkspaceDirectory() . "/themes/test_custom_theme");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the generate-theme command on a theme without a version number.
|
||||
*/
|
||||
public function testCustomStarterkit(): void {
|
||||
// Omit the version, to simulate using a custom theme as the starterkit.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
unset($info['version']);
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
|
||||
$process = $this->generateThemeFromStarterkit();
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString("The source theme starterkit_theme does not have a version specified. This makes tracking changes in the source theme difficult. Are you sure you want to continue? (yes/no) [yes]:\n > Theme generated successfully to themes/test_custom_theme", trim($process->getOutput()), $process->getErrorOutput());
|
||||
$this->assertSame(0, $result);
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayNotHasKey('hidden', $info);
|
||||
self::assertArrayHasKey('generator', $info);
|
||||
self::assertEquals('starterkit_theme:unknown-version', $info['generator']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests themes that do not exist return an error.
|
||||
*/
|
||||
public function testThemeDoesNotExist(): void {
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'foobar',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme foobar cannot be found.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that only themes with `starterkit` flag can be used.
|
||||
*/
|
||||
public function testStarterKitFlag(): void {
|
||||
// Explicitly not a starter theme.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'stark',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme stark is not a valid starter kit.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
|
||||
// Has not defined `starterkit`.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'generate-theme',
|
||||
'test_custom_theme',
|
||||
'--name="Test custom starterkit theme"',
|
||||
'--description="Custom theme generated from a starterkit theme"',
|
||||
'--starterkit',
|
||||
'olivero',
|
||||
];
|
||||
$process = new Process($install_command, NULL);
|
||||
$process->setTimeout(60);
|
||||
$result = $process->run();
|
||||
$this->assertStringContainsString('Theme source theme olivero is not a valid starter kit.', trim($process->getErrorOutput()));
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
public function testDeleteDirectory(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'ignore' => [
|
||||
'/src/*',
|
||||
'/starterkit_theme.starterkit.yml',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryExists($theme_path_absolute);
|
||||
self::assertFileDoesNotExist($theme_path_absolute . '/src/StarterKit.php');
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute . '/src');
|
||||
}
|
||||
|
||||
public function testNoEditMissingFilesWarning(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_edit' => [
|
||||
'/js/starterkit_theme.js',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
|
||||
self::assertEquals('[ERROR] Paths were defined `no_edit` but no files found.', trim($tester->getErrorOutput()));
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute);
|
||||
}
|
||||
|
||||
public function testNoRenameMissingFilesWarning(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_rename' => [
|
||||
'/js/starterkit_theme.js',
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
self::assertThat($tester->getStatusCode(), self::logicalNot(new CommandIsSuccessful()), trim($tester->getDisplay()));
|
||||
self::assertEquals('[ERROR] Paths were defined `no_rename` but no files found.', trim($tester->getErrorOutput()));
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertDirectoryDoesNotExist($theme_path_absolute);
|
||||
}
|
||||
|
||||
public function testNoRename(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_rename' => [
|
||||
'js/starterkit_theme.js',
|
||||
'**/js/*.js',
|
||||
'js/**/*.js',
|
||||
],
|
||||
]);
|
||||
|
||||
mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js');
|
||||
mkdir($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.js', '');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/starterkit_theme.foo.js', '');
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/js/baz/starterkit_theme.bar.js', '');
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.js');
|
||||
self::assertFileExists($theme_path_absolute . '/js/starterkit_theme.foo.js');
|
||||
self::assertFileExists($theme_path_absolute . '/js/baz/starterkit_theme.bar.js');
|
||||
}
|
||||
|
||||
public function testNoEdit(): void {
|
||||
$this->writeStarterkitConfig([
|
||||
'no_edit' => [
|
||||
'*no_edit_*',
|
||||
],
|
||||
]);
|
||||
$fixture = <<<FIXTURE
|
||||
# machine_name
|
||||
starterkit_theme
|
||||
# label
|
||||
Starterkit theme
|
||||
# machine_class_name
|
||||
StarterkitTheme
|
||||
# label_class_name
|
||||
StarterkitTheme
|
||||
FIXTURE;
|
||||
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/edit_fixture.txt', $fixture);
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/no_edit_fixture.txt', $fixture);
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/src/StarterkitThemePreRender.php', <<<PHP
|
||||
<?php
|
||||
|
||||
namespace Drupal\starterkit_theme;
|
||||
|
||||
use Drupal\Core\Security\TrustedCallbackInterface;
|
||||
|
||||
/**
|
||||
* Implements trusted prerender callbacks for the Starterkit theme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class StarterkitThemePreRender implements TrustedCallbackInterface {
|
||||
|
||||
}
|
||||
PHP);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
|
||||
self::assertFileExists($theme_path_absolute . '/no_edit_fixture.txt');
|
||||
self::assertEquals($fixture, file_get_contents($theme_path_absolute . '/no_edit_fixture.txt'));
|
||||
self::assertFileExists($theme_path_absolute . '/edit_fixture.txt');
|
||||
self::assertEquals(<<<EDITED
|
||||
# machine_name
|
||||
test_custom_theme
|
||||
# label
|
||||
Test custom starterkit theme
|
||||
# machine_class_name
|
||||
TestCustomTheme
|
||||
# label_class_name
|
||||
TestCustomTheme
|
||||
EDITED, file_get_contents($theme_path_absolute . '/edit_fixture.txt'));
|
||||
|
||||
self::assertEquals(<<<EDITED
|
||||
<?php
|
||||
|
||||
namespace Drupal\\test_custom_theme;
|
||||
|
||||
use Drupal\Core\Security\TrustedCallbackInterface;
|
||||
|
||||
/**
|
||||
* Implements trusted prerender callbacks for the Test custom starterkit theme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class TestCustomThemePreRender implements TrustedCallbackInterface {
|
||||
|
||||
}
|
||||
EDITED, file_get_contents($theme_path_absolute . '/src/TestCustomThemePreRender.php'));
|
||||
}
|
||||
|
||||
public function testInfoOverrides(): void {
|
||||
// Force `base theme` to be `false.
|
||||
$starterkit_info_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.info.yml';
|
||||
$info = Yaml::decode(file_get_contents($starterkit_info_yml));
|
||||
$info['base theme'] = FALSE;
|
||||
file_put_contents($starterkit_info_yml, Yaml::encode($info));
|
||||
$this->writeStarterkitConfig([
|
||||
'info' => [
|
||||
'libraries' => [
|
||||
'core/jquery',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$info = $this->assertThemeExists('themes/test_custom_theme');
|
||||
self::assertArrayHasKey('base theme', $info);
|
||||
self::assertFalse($info['base theme']);
|
||||
self::assertArrayHasKey('libraries', $info);
|
||||
self::assertEquals(['core/jquery'], $info['libraries']);
|
||||
}
|
||||
|
||||
public function testIncludeDotFiles(): void {
|
||||
file_put_contents($this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/.gitignore', '*.map');
|
||||
$tester = $this->runCommand(
|
||||
[
|
||||
'machine-name' => 'test_custom_theme',
|
||||
'--name' => 'Test custom starterkit theme',
|
||||
'--description' => 'Custom theme generated from a starterkit theme',
|
||||
]
|
||||
);
|
||||
|
||||
$tester->assertCommandIsSuccessful($tester->getErrorOutput());
|
||||
$this->assertThemeExists('themes/test_custom_theme');
|
||||
|
||||
// Verify that the .gitignore file is present in the generated theme.
|
||||
$theme_path_absolute = $this->getWorkspaceDirectory() . '/themes/test_custom_theme';
|
||||
self::assertFileExists($theme_path_absolute . '/.gitignore');
|
||||
}
|
||||
|
||||
private function writeStarterkitConfig(array $config): void {
|
||||
$starterkit_yml = $this->getWorkspaceDirectory() . '/core/themes/starterkit_theme/starterkit_theme.starterkit.yml';
|
||||
$starterkit_config = Yaml::decode(file_get_contents($starterkit_yml));
|
||||
$starterkit_config = array_replace_recursive($starterkit_config, $config);
|
||||
file_put_contents($starterkit_yml, Yaml::encode($starterkit_config));
|
||||
}
|
||||
|
||||
private function runCommand(array $input): CommandTester {
|
||||
$tester = new CommandTester(new GenerateTheme(NULL, $this->getWorkspaceDirectory()));
|
||||
$tester->execute($input, [
|
||||
'capture_stderr_separately' => TRUE,
|
||||
]);
|
||||
return $tester;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Component;
|
||||
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Try to install dependencies per component, using Composer.
|
||||
*
|
||||
* @group Composer
|
||||
* @group Component
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ComponentsIsolatedBuildTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* Provides an array with relative paths to the component paths.
|
||||
*
|
||||
* @return array
|
||||
* An array with relative paths to the component paths.
|
||||
*/
|
||||
public static function provideComponentPaths(): array {
|
||||
$data = [];
|
||||
// During the dataProvider phase, there is not a workspace directory yet.
|
||||
// So we will find relative paths and assemble them with the workspace
|
||||
// path later.
|
||||
$drupal_root = self::getDrupalRootStatic();
|
||||
$composer_json_finder = self::getComponentPathsFinder($drupal_root);
|
||||
|
||||
/** @var \Symfony\Component\Finder\SplFileInfo $path */
|
||||
foreach ($composer_json_finder->getIterator() as $path) {
|
||||
$data[$path->getRelativePath()] = ['/' . $path->getRelativePath()];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether components' composer.json can be installed in isolation.
|
||||
*
|
||||
* @dataProvider provideComponentPaths
|
||||
*/
|
||||
public function testComponentComposerJson(string $component_path): void {
|
||||
// Only copy the components. Copy all of them because some of them depend on
|
||||
// each other.
|
||||
$finder = new Finder();
|
||||
$finder->files()
|
||||
->ignoreUnreadableDirs()
|
||||
->in($this->getDrupalRoot() . static::$componentsPath)
|
||||
->ignoreDotFiles(FALSE)
|
||||
->ignoreVCS(FALSE);
|
||||
$this->copyCodebase($finder->getIterator());
|
||||
|
||||
$working_dir = $this->getWorkingPath() . static::$componentsPath . $component_path;
|
||||
|
||||
// We add path repositories so we can wire internal dependencies together.
|
||||
$this->addExpectedRepositories($working_dir);
|
||||
|
||||
// Perform the installation.
|
||||
$this->executeCommand("composer install --working-dir=$working_dir --no-interaction --no-progress");
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds expected repositories as path repositories to package under test.
|
||||
*
|
||||
* @param string $working_dir
|
||||
* The working directory.
|
||||
*/
|
||||
protected function addExpectedRepositories(string $working_dir): void {
|
||||
foreach ($this->provideComponentPaths() as $path) {
|
||||
$path = $path[0];
|
||||
$package_name = 'drupal/core' . strtolower(preg_replace('/[A-Z]/', '-$0', substr($path, 1)));
|
||||
$path_repo = $this->getWorkingPath() . static::$componentsPath . $path;
|
||||
$repo_name = strtolower($path);
|
||||
// Add path repositories with the current version number to the current
|
||||
// package under test.
|
||||
$drupal_version = Composer::drupalVersionBranch();
|
||||
$this->executeCommand("composer config repositories.$repo_name " .
|
||||
"'{\"type\": \"path\",\"url\": \"$path_repo\",\"options\": {\"versions\": {\"$package_name\": \"$drupal_version\"}}}' --working-dir=$working_dir");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Component;
|
||||
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
|
||||
/**
|
||||
* Demonstrate that the Component generator responds to release tagging.
|
||||
*
|
||||
* @group Composer
|
||||
* @group Component
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ComponentsTaggedReleaseTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* Highly arbitrary version and constraint expectations.
|
||||
*
|
||||
* @return array
|
||||
* - First element is the tag that should be applied to \Drupal::version.
|
||||
* - Second element is the resulting constraint which should be present in
|
||||
* the component core dependencies.
|
||||
*/
|
||||
public static function providerVersionConstraint(): array {
|
||||
return [
|
||||
// [Tag, constraint]
|
||||
'1.0.x-dev' => ['1.0.x-dev', '1.0.x-dev'],
|
||||
'1.0.0-beta1' => ['1.0.0-beta1', '1.0.0-beta1'],
|
||||
'1.0.0-rc1' => ['1.0.0-rc1', '1.0.0-rc1'],
|
||||
'1.0.0' => ['1.0.0', '^1.0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate release tagging and regeneration of dependencies.
|
||||
*
|
||||
* @dataProvider providerVersionConstraint
|
||||
*/
|
||||
public function testReleaseTagging(string $tag, string $constraint): void {
|
||||
$this->copyCodebase();
|
||||
$drupal_root = $this->getWorkspaceDirectory();
|
||||
|
||||
// Set the core version.
|
||||
Composer::setDrupalVersion($drupal_root, $tag);
|
||||
$this->assertDrupalVersion($tag, $drupal_root);
|
||||
|
||||
// Emulate the release script.
|
||||
// @see https://github.com/xjm/drupal_core_release/blob/main/tag.sh
|
||||
$this->executeCommand("COMPOSER_ROOT_VERSION=\"$tag\" composer update drupal/core*");
|
||||
$this->assertCommandSuccessful();
|
||||
$this->assertErrorOutputContains('generateComponentPackages');
|
||||
|
||||
// Find all the components.
|
||||
$component_finder = $this->getComponentPathsFinder($drupal_root);
|
||||
|
||||
// Loop through all the component packages.
|
||||
/** @var \Symfony\Component\Finder\SplFileInfo $composer_json */
|
||||
foreach ($component_finder->getIterator() as $composer_json) {
|
||||
$composer_json_data = json_decode(file_get_contents($composer_json->getPathname()), TRUE);
|
||||
$requires = array_merge(
|
||||
$composer_json_data['require'] ?? [],
|
||||
$composer_json_data['require-dev'] ?? []
|
||||
);
|
||||
// Required packages from drupal/core-* should have our constraint.
|
||||
foreach ($requires as $package => $req_constraint) {
|
||||
if (str_contains($package, 'drupal/core-')) {
|
||||
$this->assertEquals($constraint, $req_constraint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Base class for Composer build tests.
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
abstract class ComposerBuildTestBase extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Relative path from Drupal root to the Components directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected static $componentsPath = '/core/lib/Drupal/Component';
|
||||
|
||||
/**
|
||||
* Assert that the VERSION constant in Drupal.php is the expected value.
|
||||
*
|
||||
* @param string $expectedVersion
|
||||
* The expected version.
|
||||
* @param string $dir
|
||||
* The path to the site root.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertDrupalVersion(string $expectedVersion, string $dir): void {
|
||||
$drupal_php_path = $dir . '/core/lib/Drupal.php';
|
||||
$this->assertFileExists($drupal_php_path);
|
||||
|
||||
// Read back the Drupal version that was set and assert it matches
|
||||
// expectations
|
||||
$this->executeCommand("php -r 'include \"$drupal_php_path\"; print \Drupal::VERSION;'");
|
||||
$this->assertCommandSuccessful();
|
||||
$this->assertCommandOutputContains($expectedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all the composer.json files for components.
|
||||
*
|
||||
* @param string $drupal_root
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @return \Symfony\Component\Finder\Finder
|
||||
* A Finder object with all the composer.json files for components.
|
||||
*/
|
||||
protected static function getComponentPathsFinder(string $drupal_root): Finder {
|
||||
$finder = new Finder();
|
||||
$finder->name('composer.json')
|
||||
->in($drupal_root . static::$componentsPath)
|
||||
->ignoreUnreadableDirs()
|
||||
->depth(1);
|
||||
|
||||
return $finder;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Tests\Composer\ComposerIntegrationTrait;
|
||||
|
||||
/**
|
||||
* @group Composer
|
||||
*/
|
||||
class ComposerValidateTest extends BuildTestBase {
|
||||
|
||||
use ComposerIntegrationTrait;
|
||||
|
||||
public static function provideComposerJson() {
|
||||
$data = [];
|
||||
$composer_json_finder = self::getComposerJsonFinder(self::getDrupalRootStatic());
|
||||
foreach ($composer_json_finder->getIterator() as $composer_json) {
|
||||
$data[] = [$composer_json->getPathname()];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideComposerJson
|
||||
*/
|
||||
public function testValidateComposer($path): void {
|
||||
$this->executeCommand('composer validate --strict --no-check-all ' . $path);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,683 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Plugin\Unpack\Functional;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Composer\Util\Filesystem;
|
||||
use Drupal\Tests\Composer\Plugin\Unpack\Fixtures;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Tests\Composer\Plugin\ExecTrait;
|
||||
|
||||
/**
|
||||
* Tests recipe unpacking.
|
||||
*
|
||||
* @group Unpack
|
||||
*/
|
||||
class UnpackRecipeTest extends BuildTestBase {
|
||||
|
||||
use ExecTrait;
|
||||
|
||||
/**
|
||||
* Directory to perform the tests in.
|
||||
*/
|
||||
protected string $fixturesDir;
|
||||
|
||||
/**
|
||||
* The Symfony FileSystem component.
|
||||
*
|
||||
* @var \Composer\Util\Filesystem
|
||||
*/
|
||||
protected Filesystem $fileSystem;
|
||||
|
||||
/**
|
||||
* The Fixtures object.
|
||||
*
|
||||
* @var \Drupal\Tests\Composer\Plugin\Unpack\Fixtures
|
||||
*/
|
||||
protected Fixtures $fixtures;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->fileSystem = new Filesystem();
|
||||
$this->fixtures = new Fixtures();
|
||||
$this->fixtures->createIsolatedComposerCacheDir();
|
||||
$this->fixturesDir = $this->fixtures->tmpDir($this->name());
|
||||
$replacements = [
|
||||
'PROJECT_ROOT' => $this->fixtures->projectRoot(),
|
||||
'COMPOSER_INSTALLERS' => InstalledVersions::getInstallPath('composer/installers'),
|
||||
];
|
||||
$this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
// Remove any temporary directories that were created.
|
||||
$this->fixtures->tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dependencies unpack on install.
|
||||
*/
|
||||
public function testAutomaticUnpack(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
copy($root_project_path . '/composer.json', $root_project_path . '/composer.json.original');
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a module in require-dev that should be moved to require
|
||||
// by the unpacker.
|
||||
$this->runComposer('require --dev fixtures/module-a:^1.0');
|
||||
// Ensure we have added the dependency to require-dev.
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
// Install a recipe and unpack it.
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
// The more specific constraint should have been used.
|
||||
$this->assertSame("^1.0", $root_composer_json['require']['fixtures/module-a']);
|
||||
|
||||
// Copy old composer.json back over and require recipe again to ensure it
|
||||
// is still unpacked. This tests that unpacking does not rely on composer
|
||||
// package events.
|
||||
unlink($root_project_path . '/composer.json');
|
||||
copy($root_project_path . '/composer.json.original', $root_project_path . '/composer.json');
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests recursive unpacking.
|
||||
*/
|
||||
public function testRecursiveUnpacking(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('config --merge --json sort-packages true');
|
||||
$this->runComposer('install');
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-c fixtures/recipe-a');
|
||||
$this->assertSame("fixtures/recipe-c unpacked.\nfixtures/recipe-a unpacked.\nfixtures/recipe-b unpacked.\n", $stdOut);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'ext-json',
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
// Ensure the resulting composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
// Ensure the recipes exist.
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-c/recipe.yml');
|
||||
|
||||
// Ensure the complex constraint has been written correctly.
|
||||
$this->assertSame('>=2.0.1.0-dev, <3.0.0.0-dev', $root_composer_json['require']['fixtures/module-b']);
|
||||
|
||||
// Ensure composer.lock is ordered correctly.
|
||||
$root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_column($root_composer_lock['packages'], 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dev dependencies do not unpack on install.
|
||||
*/
|
||||
public function testNoAutomaticDevUnpack(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a module in require.
|
||||
$this->runComposer('require fixtures/module-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
|
||||
// Install a recipe as a dev dependency.
|
||||
$stdout = $this->runComposer('require --dev fixtures/recipe-a');
|
||||
$this->assertStringContainsString("Recipes required as a development dependency are not automatically unpacked.", $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Assert the state of the root composer.json as no unpacking has occurred.
|
||||
$this->assertSame(['fixtures/recipe-a'], array_keys($root_composer_json['require-dev']));
|
||||
$this->assertSame(['composer/installers', 'drupal/core-recipe-unpack', 'ext-json', 'fixtures/module-a'], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack.
|
||||
*/
|
||||
public function testUnpackCommand(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a module in require-dev.
|
||||
$this->runComposer('require --dev fixtures/module-a');
|
||||
// Install a module in require.
|
||||
$this->runComposer('require fixtures/module-b:*');
|
||||
|
||||
// Ensure we have added the dependencies.
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdout = $this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdout);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
// The package dependencies should not be in the root composer.json.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
|
||||
// Try unpacking a recipe that in not in the root composer.json.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-b');
|
||||
$this->fail('Unpacking a non-existent dependency should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/recipe-b not found in the root composer.json.', $e->getMessage());
|
||||
}
|
||||
|
||||
// The dev dependency has not moved.
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require-dev']);
|
||||
|
||||
$stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
// The more specific constraints has been used.
|
||||
$this->assertSame("^2.0", $root_composer_json['require']['fixtures/module-b']);
|
||||
|
||||
// Try unpacking something that is not a recipe.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/module-a');
|
||||
$this->fail('Unpacking a module should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/module-a is not a recipe.', $e->getMessage());
|
||||
}
|
||||
|
||||
// Try unpacking something that in not in the root composer.json.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/module-c');
|
||||
$this->fail('Unpacking a non-existent dependency should fail');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/module-c not found in the root composer.json.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack with multiple args.
|
||||
*/
|
||||
public function testUnpackCommandWithMultipleRecipes(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdOut);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a fixtures/recipe-d');
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut);
|
||||
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dependency unpacking using drupal:recipe-unpack with no arguments.
|
||||
*/
|
||||
public function testUnpackCommandWithoutRecipesArgument(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('install');
|
||||
|
||||
// Tests unpack command with no arguments and no recipes in the root
|
||||
// composer package.
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack');
|
||||
$this->assertSame("No recipes to unpack.\n", $stdOut);
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$stdOut = $this->runComposer('require fixtures/recipe-a fixtures/recipe-d');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringNotContainsString("unpacked.", $stdOut);
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
|
||||
$stdOut = $this->runComposer('drupal:recipe-unpack');
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-d unpacked.", $stdOut);
|
||||
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-d', $root_composer_json['require']);
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests unpacking a recipe in require-dev using drupal:recipe-unpack.
|
||||
*/
|
||||
public function testUnpackCommandOnDevRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior.
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('require fixtures/recipe-b');
|
||||
|
||||
// Install a recipe and check it is not unpacked.
|
||||
$this->runComposer('require --dev fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require-dev']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
|
||||
$error_output = '';
|
||||
$stdout = $this->runComposer('drupal:recipe-unpack fixtures/recipe-a', error_output: $error_output);
|
||||
$this->assertStringContainsString("fixtures/recipe-a is present in the require-dev key. Unpacking will move the recipe's dependencies to the require key.", $error_output);
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Ensure recipe A's dependencies are moved to require.
|
||||
$this->doTestRecipeAUnpacked($root_project_path, $stdout);
|
||||
|
||||
// Ensure recipe B's dependencies are in require and the recipe has been
|
||||
// unpacked.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']);
|
||||
|
||||
// Ensure installed.json and installed.php are correct.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/module-b', $installed_packages);
|
||||
$this->assertNotContains('fixtures/recipe-a', $installed_packages);
|
||||
$this->assertSame([], $installed_json['dev-package-names']);
|
||||
$installed_php = include_once $root_project_path . '/vendor/composer/installed.php';
|
||||
$this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']);
|
||||
$this->assertFalse($installed_php['versions']['fixtures/module-b']['dev_requirement']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $installed_php['versions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the unpacking a recipe that is an indirect dev dependency.
|
||||
*/
|
||||
public function testUnpackCommandOnIndirectDevDependencyRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Run composer install and confirm the composer.lock was created.
|
||||
$this->runComposer('install');
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('require --dev fixtures/recipe-b');
|
||||
|
||||
// Install a recipe and ensure it is not unpacked.
|
||||
$this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
$this->assertArrayHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/recipe-b', $root_composer_json['require-dev']);
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// Ensure recipe A's dependencies are in require.
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-b', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/module-a', $root_composer_json['require']);
|
||||
$this->assertArrayHasKey('fixtures/theme-a', $root_composer_json['require']);
|
||||
|
||||
// Ensure recipe B is still in require-dev even though all it's dependencies
|
||||
// have been unpacked to require due to unpacking recipe A.
|
||||
$this->assertSame(['fixtures/recipe-b'], array_keys($root_composer_json['require-dev']));
|
||||
|
||||
// Ensure recipe B is still list in installed.json.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/recipe-b', $installed_packages);
|
||||
$this->assertContains('fixtures/recipe-b', $installed_json['dev-package-names']);
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be removed and the unpack plugin does not interfere.
|
||||
*/
|
||||
public function testRemoveRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.on-require false');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and ensure it is not unpacked.
|
||||
$this->runComposer('require fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Removing the recipe should work as normal.
|
||||
$this->runComposer('remove fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be ignored and not unpacked.
|
||||
*/
|
||||
public function testIgnoreRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking as it is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-a"]\'');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and ensure it does not get unpacked.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame("fixtures/recipe-a not unpacked because it is ignored.", trim($stdOut));
|
||||
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
|
||||
// Try using the unpack command on an ignored recipe.
|
||||
try {
|
||||
$this->runComposer('drupal:recipe-unpack fixtures/recipe-a');
|
||||
$this->fail('Ignored recipes should not be unpacked.');
|
||||
}
|
||||
catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('fixtures/recipe-a is in the extra.drupal-recipe-unpack.ignore list.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a dependent recipe can be ignored and not unpacked.
|
||||
*/
|
||||
public function testIgnoreDependentRecipe(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Disable automatic unpacking, which is the default behavior,
|
||||
$this->runComposer('config --merge --json extra.drupal-recipe-unpack.ignore \'["fixtures/recipe-b"]\'');
|
||||
$this->runComposer('config sort-packages true');
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and check it is not packed but not removed.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertStringContainsString("fixtures/recipe-b not unpacked because it is ignored.", $stdOut);
|
||||
$this->assertStringContainsString("fixtures/recipe-a unpacked.", $stdOut);
|
||||
|
||||
$this->assertSame([
|
||||
'ext-json',
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-b',
|
||||
'fixtures/recipe-b',
|
||||
], array_keys($root_composer_json['require']));
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that recipes stick around after being unpacked.
|
||||
*/
|
||||
public function testRecipeIsPhysicallyPresentAfterUnpack(): void {
|
||||
$root_project_dir = 'composer-root';
|
||||
$root_project_path = $this->fixturesDir . '/' . $root_project_dir;
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe, which should unpack it.
|
||||
$stdOut = $this->runComposer('require --verbose fixtures/recipe-b');
|
||||
$this->assertStringContainsString("fixtures/recipe-b unpacked.", $stdOut);
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// Require another dependency.
|
||||
$this->runComposer('require --verbose fixtures/module-b');
|
||||
|
||||
// The recipe should still be physically installed...
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// ...but it should NOT be in installed.json or installed.php.
|
||||
$installed_json = $this->getFileContents($root_project_path . '/vendor/composer/installed.json');
|
||||
$installed_packages = array_column($installed_json['packages'], 'name');
|
||||
$this->assertContains('fixtures/module-b', $installed_packages);
|
||||
$this->assertNotContains('fixtures/recipe-b', $installed_packages);
|
||||
$installed_php = include_once $root_project_path . '/vendor/composer/installed.php';
|
||||
$this->assertArrayHasKey('fixtures/module-b', $installed_php['versions']);
|
||||
$this->assertArrayNotHasKey('fixtures/recipe-b', $installed_php['versions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a recipe can be required using --no-install and installed later.
|
||||
*/
|
||||
public function testRecipeNotUnpackedIfInstallIsDeferred(): void {
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
|
||||
$this->runComposer('install');
|
||||
|
||||
// Install a recipe and check it is in `composer.json` but not unpacked or
|
||||
// physically installed.
|
||||
$stdOut = $this->runComposer('require --verbose --no-install fixtures/recipe-a');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame("Recipes are not unpacked when the --no-install option is used.", trim($stdOut));
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
$this->assertFileDoesNotExist($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
|
||||
// After installing dependencies, the recipe should be installed, but still
|
||||
// not unpacked.
|
||||
$this->runComposer('install');
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'ext-json',
|
||||
'fixtures/recipe-a',
|
||||
], array_keys($root_composer_json['require']));
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that recipes are unpacked when using `composer create-project`.
|
||||
*/
|
||||
public function testComposerCreateProject(): void {
|
||||
// Prepare the project to use for create-project.
|
||||
$root_project_path = $this->fixturesDir . '/composer-root';
|
||||
$this->runComposer('require --verbose --no-install fixtures/recipe-a');
|
||||
|
||||
$stdOut = $this->runComposer('create-project --repository=\'{"type": "path","url": "' . $root_project_path . '","options": {"symlink": false}}\' fixtures/root composer-root2 -s dev', $this->fixturesDir);
|
||||
// The recipes depended upon by the project, even indirectly, should all
|
||||
// have been unpacked.
|
||||
$this->assertSame("fixtures/recipe-b unpacked.\nfixtures/recipe-a unpacked.\n", $stdOut);
|
||||
$this->doTestRecipeAUnpacked($this->fixturesDir . '/composer-root2', $stdOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Recipe A is unpacked correctly.
|
||||
*
|
||||
* @param string $root_project_path
|
||||
* Path to the composer project under test.
|
||||
* @param string $stdout
|
||||
* The standard out from the composer command unpacks the recipe.
|
||||
*/
|
||||
private function doTestRecipeAUnpacked(string $root_project_path, string $stdout): void {
|
||||
$root_composer_json = $this->getFileContents($root_project_path . '/composer.json');
|
||||
|
||||
// @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-a/composer.json
|
||||
// @see core/tests/Drupal/Tests/Composer/Plugin/Unpack/fixtures/recipes/composer-recipe-b/composer.json
|
||||
$expected_unpacked = [
|
||||
'fixtures/recipe-a' => [
|
||||
'fixtures/module-b',
|
||||
],
|
||||
'fixtures/recipe-b' => [
|
||||
'fixtures/module-a',
|
||||
'fixtures/theme-a',
|
||||
],
|
||||
];
|
||||
foreach ($expected_unpacked as $package => $dependencies) {
|
||||
// When the package is unpacked, the unpacked dependencies should be logged
|
||||
// in the stdout.
|
||||
$this->assertStringContainsString("$package unpacked.", $stdout);
|
||||
|
||||
// After being unpacked, the package should be removed from the root
|
||||
// composer.json and composer.lock.
|
||||
$this->assertArrayNotHasKey($package, $root_composer_json['require']);
|
||||
|
||||
foreach ($dependencies as $dependency) {
|
||||
// The package dependencies should be in the root composer.json.
|
||||
$this->assertArrayHasKey($dependency, $root_composer_json['require']);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the resulting Composer files are valid.
|
||||
$this->runComposer('validate', $root_project_path);
|
||||
|
||||
// The dev dependency has moved.
|
||||
$this->assertArrayNotHasKey('require-dev', $root_composer_json);
|
||||
|
||||
// Ensure recipe files exist.
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-a/recipe.yml');
|
||||
$this->assertFileExists($root_project_path . '/recipes/recipe-b/recipe.yml');
|
||||
|
||||
// Ensure composer.lock is ordered correctly.
|
||||
$root_composer_lock = $this->getFileContents($root_project_path . '/composer.lock');
|
||||
$this->assertSame([
|
||||
'composer/installers',
|
||||
'drupal/core-recipe-unpack',
|
||||
'fixtures/module-a',
|
||||
'fixtures/module-b',
|
||||
'fixtures/theme-a',
|
||||
], array_column($root_composer_lock['packages'], 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Composer command with standard options.
|
||||
*
|
||||
* @param string $command
|
||||
* The composer command to execute.
|
||||
* @param string $cwd
|
||||
* The current working directory to run the command from.
|
||||
* @param string $error_output
|
||||
* Passed by reference to allow error output to be tested.
|
||||
*
|
||||
* @return string
|
||||
* Standard output from the command.
|
||||
*/
|
||||
private function runComposer(string $command, ?string $cwd = NULL, string &$error_output = ''): string {
|
||||
$cwd ??= $this->fixturesDir . '/composer-root';
|
||||
|
||||
// Always add --no-interaction and --no-ansi to Composer commands.
|
||||
$output = $this->mustExec("composer $command --no-interaction --no-ansi", $cwd, [], $error_output);
|
||||
if ($command === 'install') {
|
||||
$this->assertFileExists($cwd . '/composer.lock');
|
||||
}
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the contents of a file as an array.
|
||||
*
|
||||
* @param string $path
|
||||
* The path to the file.
|
||||
*
|
||||
* @return array
|
||||
* The contents of the file as an array.
|
||||
*/
|
||||
private function getFileContents(string $path): array {
|
||||
$file = file_get_contents($path);
|
||||
return json_decode($file, TRUE, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Composer\Template;
|
||||
|
||||
use Composer\Json\JsonFile;
|
||||
use Composer\Semver\VersionParser;
|
||||
use Drupal\BuildTests\Composer\ComposerBuildTestBase;
|
||||
use Drupal\Composer\Composer;
|
||||
|
||||
/**
|
||||
* Demonstrate that Composer project templates can be built as patched.
|
||||
*
|
||||
* We have to use the packages.json fixture so that Composer will use the
|
||||
* in-codebase version of the project template.
|
||||
*
|
||||
* We also have to add path repositories to the in-codebase project template or
|
||||
* else Composer will try to use packagist to resolve dependencies we'd prefer
|
||||
* it to find locally.
|
||||
*
|
||||
* This is because Composer only uses the packages.json file to resolve the
|
||||
* project template and not any other dependencies.
|
||||
*
|
||||
* @group Template
|
||||
*/
|
||||
class ComposerProjectTemplatesTest extends ComposerBuildTestBase {
|
||||
|
||||
/**
|
||||
* The minimum stability requirement for dependencies.
|
||||
*
|
||||
* @see https://getcomposer.org/doc/04-schema.md#minimum-stability
|
||||
*/
|
||||
protected const MINIMUM_STABILITY = 'stable';
|
||||
|
||||
/**
|
||||
* The order of stability strings from least stable to most stable.
|
||||
*
|
||||
* This only includes normalized stability strings: i.e., ones that are
|
||||
* returned by \Composer\Semver\VersionParser::parseStability().
|
||||
*/
|
||||
protected const STABILITY_ORDER = ['dev', 'alpha', 'beta', 'RC', 'stable'];
|
||||
|
||||
/**
|
||||
* Get Composer items that we want to be path repos, from within a directory.
|
||||
*
|
||||
* @param string $workspace_directory
|
||||
* The full path to the workspace directory.
|
||||
* @param string $subdir
|
||||
* The subdirectory to search under composer/.
|
||||
*
|
||||
* @return string[]
|
||||
* Array of paths, indexed by package name.
|
||||
*/
|
||||
public function getPathReposForType($workspace_directory, $subdir) {
|
||||
// Find the Composer items that we want to be path repos.
|
||||
/** @var \SplFileInfo[] $path_repos */
|
||||
$path_repos = Composer::composerSubprojectPaths($workspace_directory, $subdir);
|
||||
|
||||
$data = [];
|
||||
foreach ($path_repos as $path_repo) {
|
||||
$json_file = new JsonFile($path_repo->getPathname());
|
||||
$json = $json_file->read();
|
||||
$data[$json['name']] = $path_repo->getPath();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function provideTemplateCreateProject() {
|
||||
return [
|
||||
'recommended-project' => [
|
||||
'drupal/recommended-project',
|
||||
'composer/Template/RecommendedProject',
|
||||
'/web',
|
||||
],
|
||||
'legacy-project' => [
|
||||
'drupal/legacy-project',
|
||||
'composer/Template/LegacyProject',
|
||||
'',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that static::MINIMUM_STABILITY is sufficiently strict.
|
||||
*/
|
||||
public function testMinimumStabilityStrictness(): void {
|
||||
// Ensure that static::MINIMUM_STABILITY is not less stable than the
|
||||
// current core stability. For example, if we've already released a beta on
|
||||
// the branch, ensure that we no longer allow alpha dependencies.
|
||||
$this->assertGreaterThanOrEqual(array_search($this->getCoreStability(), static::STABILITY_ORDER), array_search(static::MINIMUM_STABILITY, static::STABILITY_ORDER));
|
||||
|
||||
// Ensure that static::MINIMUM_STABILITY is the same as the least stable
|
||||
// dependency.
|
||||
// - We can't set it stricter than our least stable dependency.
|
||||
// - We don't want to set it looser than we need to, because we don't want
|
||||
// to in the future accidentally commit a dependency that regresses our
|
||||
// actual stability requirement without us explicitly changing this
|
||||
// constant.
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("composer --working-dir=$root info --format=json");
|
||||
$this->assertCommandSuccessful();
|
||||
$installed = json_decode($process->getOutput(), TRUE);
|
||||
|
||||
// A lookup of the numerical position of each of the stability terms.
|
||||
$stability_order_indexes = array_flip(static::STABILITY_ORDER);
|
||||
|
||||
$minimum_stability_order_index = $stability_order_indexes[static::MINIMUM_STABILITY];
|
||||
|
||||
$exclude = [
|
||||
'drupal/core',
|
||||
'drupal/core-recipe-unpack',
|
||||
'drupal/core-project-message',
|
||||
'drupal/core-vendor-hardening',
|
||||
];
|
||||
foreach ($installed['installed'] as $project) {
|
||||
// Exclude dependencies that are required with "self.version", since
|
||||
// those stabilities will automatically match the corresponding Drupal
|
||||
// release.
|
||||
if (in_array($project['name'], $exclude, TRUE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// VersionParser::parseStability doesn't play nice with (mostly dev-)
|
||||
// versions ending with the first seven characters of the commit ID as
|
||||
// returned by "composer info". Let's strip those suffixes here.
|
||||
$version = preg_replace('/ [0-9a-f]{7}$/i', '', $project['version']);
|
||||
|
||||
$project_stability = VersionParser::parseStability($version);
|
||||
$project_stability_order_index = $stability_order_indexes[$project_stability];
|
||||
|
||||
$project_stabilities[$project['name']] = $project_stability;
|
||||
|
||||
$this->assertGreaterThanOrEqual($minimum_stability_order_index, $project_stability_order_index, sprintf(
|
||||
"Dependency %s with stability %s does not meet minimum stability %s.",
|
||||
$project['name'],
|
||||
$project_stability,
|
||||
static::MINIMUM_STABILITY,
|
||||
));
|
||||
}
|
||||
|
||||
// At least one project should be at the minimum stability.
|
||||
$this->assertContains(static::MINIMUM_STABILITY, $project_stabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure we've accounted for all the templates.
|
||||
*/
|
||||
public function testVerifyTemplateTestProviderIsAccurate(): void {
|
||||
$root = $this->getDrupalRoot();
|
||||
$data = $this->provideTemplateCreateProject();
|
||||
|
||||
// Find all the templates.
|
||||
$template_files = Composer::composerSubprojectPaths($root, 'Template');
|
||||
|
||||
$this->assertSameSize($template_files, $data);
|
||||
|
||||
// We could have the same number of templates but different names.
|
||||
$template_data = [];
|
||||
foreach ($data as $data_name => $data_value) {
|
||||
$template_data[$data_value[0]] = $data_name;
|
||||
}
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($template_files as $file) {
|
||||
$json_file = new JsonFile($file->getPathname());
|
||||
$json = $json_file->read();
|
||||
$this->assertArrayHasKey('name', $json);
|
||||
// Assert that the template name is in the project created
|
||||
// from the template.
|
||||
$this->assertArrayHasKey($json['name'], $template_data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideTemplateCreateProject
|
||||
*/
|
||||
public function testTemplateCreateProject($project, $package_dir, $docroot_dir): void {
|
||||
// Make a working COMPOSER_HOME directory for setting global composer config
|
||||
$composer_home = $this->getWorkspaceDirectory() . '/composer-home';
|
||||
mkdir($composer_home);
|
||||
// Create an empty global composer.json file, just to avoid warnings.
|
||||
file_put_contents("$composer_home/composer.json", '{}');
|
||||
|
||||
// Disable packagist globally (but only in our own custom COMPOSER_HOME).
|
||||
// It is necessary to do this globally rather than in our SUT composer.json
|
||||
// in order to ensure that Packagist is disabled during the
|
||||
// `composer create-project` command.
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home composer config --no-interaction --global repo.packagist false");
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Create a "Composer"-type repository containing one entry for every
|
||||
// package in the vendor directory.
|
||||
$vendor_packages_path = $this->getWorkspaceDirectory() . '/vendor_packages/packages.json';
|
||||
$this->makeVendorPackage($vendor_packages_path);
|
||||
|
||||
// Make a copy of the code to alter in the workspace directory.
|
||||
$this->copyCodebase();
|
||||
|
||||
// Tests are typically run on "-dev" versions, but we want to simulate
|
||||
// running them on a tagged release at the same stability as specified in
|
||||
// static::MINIMUM_STABILITY, in order to verify that everything will work
|
||||
// if/when we make such a release.
|
||||
$simulated_core_version = \Drupal::VERSION;
|
||||
$simulated_core_version_suffix = (static::MINIMUM_STABILITY === 'stable' ? '' : '-' . static::MINIMUM_STABILITY . '99');
|
||||
$simulated_core_version = str_replace('-dev', $simulated_core_version_suffix, $simulated_core_version);
|
||||
Composer::setDrupalVersion($this->getWorkspaceDirectory(), $simulated_core_version);
|
||||
$this->assertDrupalVersion($simulated_core_version, $this->getWorkspaceDirectory());
|
||||
|
||||
// Remove the packages.drupal.org entry (and any other custom repository)
|
||||
// from the SUT's repositories section. There is no way to do this via
|
||||
// `composer config --unset`, so we read and rewrite composer.json.
|
||||
$composer_json_path = $this->getWorkspaceDirectory() . "/$package_dir/composer.json";
|
||||
$composer_json = json_decode(file_get_contents($composer_json_path), TRUE);
|
||||
unset($composer_json['repositories']);
|
||||
$json = json_encode($composer_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
file_put_contents($composer_json_path, $json);
|
||||
|
||||
// Set up the template to use our path repos. Inclusion of metapackages is
|
||||
// reported differently, so we load up a separate set for them.
|
||||
$metapackage_path_repos = $this->getPathReposForType($this->getWorkspaceDirectory(), 'Metapackage');
|
||||
$this->assertArrayHasKey('drupal/core-recommended', $metapackage_path_repos);
|
||||
$path_repos = array_merge($metapackage_path_repos, $this->getPathReposForType($this->getWorkspaceDirectory(), 'Plugin'));
|
||||
// Always add drupal/core as a path repo.
|
||||
$path_repos['drupal/core'] = $this->getWorkspaceDirectory() . '/core';
|
||||
foreach ($path_repos as $name => $path) {
|
||||
$this->executeCommand("composer config --no-interaction repositories.$name path $path", $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
}
|
||||
|
||||
// Change drupal/core-recommended to require the simulated version of
|
||||
// drupal/core.
|
||||
$core_recommended_dir = 'composer/Metapackage/CoreRecommended';
|
||||
$this->executeCommand("composer remove --no-interaction drupal/core --no-update", $core_recommended_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
$this->executeCommand("composer require --no-interaction drupal/core:^$simulated_core_version --no-update", $core_recommended_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Add our vendor package repository to our SUT's repositories section.
|
||||
// Call it "local" (although the name does not matter).
|
||||
$this->executeCommand("composer config --no-interaction repositories.local composer file://" . $vendor_packages_path, $package_dir);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
$repository_path = $this->getWorkspaceDirectory() . '/test_repository/packages.json';
|
||||
$this->makeTestPackage($repository_path, $simulated_core_version);
|
||||
|
||||
$installed_composer_json = $this->getWorkspaceDirectory() . '/test_project/composer.json';
|
||||
$autoloader = $this->getWorkspaceDirectory() . '/test_project' . $docroot_dir . '/autoload.php';
|
||||
$recipes_dir = $this->getWorkspaceDirectory() . '/test_project/recipes';
|
||||
$this->assertFileDoesNotExist($autoloader);
|
||||
$this->assertDirectoryDoesNotExist($recipes_dir);
|
||||
|
||||
$this->executeCommand("COMPOSER_HOME=$composer_home COMPOSER_ROOT_VERSION=$simulated_core_version composer create-project --no-ansi $project test_project $simulated_core_version -vvv --repository $repository_path");
|
||||
$this->assertCommandSuccessful();
|
||||
// Check the output of the project creation for the absence of warnings
|
||||
// about any non-allowed composer plugins.
|
||||
// Note: There are different warnings for disallowed composer plugins
|
||||
// depending on running in non-interactive mode or not. It seems the Drupal
|
||||
// CI environment always forces composer commands to run in the
|
||||
// non-interactive mode. The only thing these messages have in common is the
|
||||
// following string.
|
||||
$this->assertErrorOutputNotContains('See https://getcomposer.org/allow-plugins');
|
||||
|
||||
// Ensure we used the project from our codebase.
|
||||
$this->assertErrorOutputContains("Installing $project ($simulated_core_version): Symlinking from $package_dir");
|
||||
// Ensure that we used drupal/core from our codebase. This probably means
|
||||
// that drupal/core-recommended was added successfully by the project.
|
||||
$this->assertErrorOutputContains("Installing drupal/core ($simulated_core_version): Symlinking from");
|
||||
// Verify that there is an autoloader. This is written by the scaffold
|
||||
// plugin, so its existence assures us that scaffolding happened.
|
||||
$this->assertFileExists($autoloader);
|
||||
// Verify recipes directory exists.
|
||||
$this->assertDirectoryExists($recipes_dir);
|
||||
|
||||
// Verify that the minimum stability in the installed composer.json file
|
||||
// matches the stability of the simulated core version.
|
||||
$this->assertFileExists($installed_composer_json);
|
||||
$composer_json_contents = file_get_contents($installed_composer_json);
|
||||
$this->assertStringContainsString('"minimum-stability": "' . static::MINIMUM_STABILITY . '"', $composer_json_contents);
|
||||
|
||||
// In order to verify that Composer used the path repos for our project, we
|
||||
// have to get the requirements from the project composer.json so we can
|
||||
// reconcile our expectations.
|
||||
$template_json_file = $this->getWorkspaceDirectory() . '/' . $package_dir . '/composer.json';
|
||||
$this->assertFileExists($template_json_file);
|
||||
$json_file = new JsonFile($template_json_file);
|
||||
$template_json = $json_file->read();
|
||||
// Get the require and require-dev information, and ensure that our
|
||||
// requirements are not erroneously empty.
|
||||
$this->assertNotEmpty(
|
||||
$require = array_merge($template_json['require'] ?? [], $template_json['require-dev'] ?? [])
|
||||
);
|
||||
// Verify that path repo packages were installed.
|
||||
$path_repos = array_keys($path_repos);
|
||||
foreach (array_keys($require) as $package_name) {
|
||||
if (in_array($package_name, $path_repos)) {
|
||||
// Metapackages do not report that they were installed as symlinks, but
|
||||
// we still must check that their installed version matches
|
||||
// COMPOSER_CORE_VERSION.
|
||||
if (array_key_exists($package_name, $metapackage_path_repos)) {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($simulated_core_version)");
|
||||
}
|
||||
else {
|
||||
$this->assertErrorOutputContains("Installing $package_name ($simulated_core_version): Symlinking from");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to the templates.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
* @param string $version
|
||||
* The version under test.
|
||||
*/
|
||||
protected function makeTestPackage($repository_path, $version): void {
|
||||
$json = <<<JSON
|
||||
{
|
||||
"packages": {
|
||||
"drupal/recommended-project": {
|
||||
"$version": {
|
||||
"name": "drupal/recommended-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/RecommendedProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
},
|
||||
"drupal/legacy-project": {
|
||||
"$version": {
|
||||
"name": "drupal/legacy-project",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "composer/Template/LegacyProject"
|
||||
},
|
||||
"type": "project",
|
||||
"version": "$version"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON;
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test package that points to all the projects in vendor.
|
||||
*
|
||||
* @param string $repository_path
|
||||
* The path where to create the test package.
|
||||
*/
|
||||
protected function makeVendorPackage($repository_path): void {
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("composer --working-dir=$root info --format=json");
|
||||
$this->assertCommandSuccessful();
|
||||
$installed = json_decode($process->getOutput(), TRUE);
|
||||
|
||||
// Build out package definitions for everything installed in
|
||||
// the vendor directory.
|
||||
$packages = [];
|
||||
foreach ($installed['installed'] as $project) {
|
||||
$name = $project['name'];
|
||||
$version = $project['version'];
|
||||
$path = "vendor/$name";
|
||||
$full_path = "$root/$path";
|
||||
// We are building a set of path repositories to projects in the vendor
|
||||
// directory, so we will skip any project that does not exist in vendor.
|
||||
// Also skip the projects that are symlinked in vendor. These are in our
|
||||
// metapackage. They will be represented as path repositories in the test
|
||||
// project's composer.json.
|
||||
if (is_dir($full_path) && !is_link($full_path)) {
|
||||
$packages['packages'][$name] = [
|
||||
$version => [
|
||||
"name" => $name,
|
||||
"dist" => [
|
||||
"type" => "path",
|
||||
"url" => $full_path,
|
||||
],
|
||||
"version" => $version,
|
||||
],
|
||||
];
|
||||
// Ensure composer plugins are registered correctly.
|
||||
$package_json = json_decode(file_get_contents($full_path . '/composer.json'), TRUE);
|
||||
if (isset($package_json['type']) && $package_json['type'] === 'composer-plugin') {
|
||||
$packages['packages'][$name][$version]['type'] = $package_json['type'];
|
||||
$packages['packages'][$name][$version]['require'] = $package_json['require'];
|
||||
$packages['packages'][$name][$version]['extra'] = $package_json['extra'];
|
||||
if (isset($package_json['autoload'])) {
|
||||
$packages['packages'][$name][$version]['autoload'] = $package_json['autoload'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($packages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
mkdir(dirname($repository_path));
|
||||
file_put_contents($repository_path, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stability of the current core version.
|
||||
*
|
||||
* If the current core version is a tagged release (not a "-dev" version),
|
||||
* this returns the stability of that version.
|
||||
*
|
||||
* If the current core version is a "-dev" version, but not a "x.y.0-dev"
|
||||
* version, this returns "stable", because it means that the corresponding
|
||||
* "x.y.0" has already been released, and only stable changes are now
|
||||
* permitted on the branch.
|
||||
*
|
||||
* If the current core version is a "x.y.0-dev" version, then this returns
|
||||
* the stability of the latest tag that matches "x.y.0-*". For example, if
|
||||
* we've already released "x.y.0-alpha1" but have not yet released
|
||||
* "x.y.0-beta1", then the current stability is "alpha". If there aren't any
|
||||
* matching tags, this returns "dev", because it means that an "alpha1" has
|
||||
* not yet been released.
|
||||
*
|
||||
* @return string
|
||||
* One of: "dev", "alpha", "beta", "RC", "stable".
|
||||
*/
|
||||
protected function getCoreStability() {
|
||||
$version = \Drupal::VERSION;
|
||||
// If the current version is x.y-dev then this is the equivalent of the main
|
||||
// branch and should be treated as a dev release.
|
||||
if (preg_match('/^(\d)+\.(\d)+-dev$/', $version)) {
|
||||
return 'dev';
|
||||
}
|
||||
$stability = VersionParser::parseStability($version);
|
||||
if ($stability === 'dev') {
|
||||
// Strip off "-dev";
|
||||
$version_towards = substr($version, 0, -4);
|
||||
|
||||
if (!str_ends_with($version_towards, '.0')) {
|
||||
// If the current version is developing towards an x.y.z release where
|
||||
// z is not 0, it means that the x.y.0 has already been released, and
|
||||
// only stable changes are permitted on the branch.
|
||||
$stability = 'stable';
|
||||
}
|
||||
else {
|
||||
// If the current version is developing towards an x.y.0 release, there
|
||||
// might be tagged pre-releases. "git describe" identifies the latest
|
||||
// one.
|
||||
$root = $this->getDrupalRoot();
|
||||
$process = $this->executeCommand("git -C \"$root\" describe --abbrev=0 --match=\"$version_towards-*\"");
|
||||
|
||||
// If there aren't any tagged pre-releases for this version yet, return
|
||||
// 'dev'. Ensure that any other error from "git describe" causes a test
|
||||
// failure.
|
||||
if (!$process->isSuccessful()) {
|
||||
$this->assertErrorOutputContains('No names found, cannot describe anything.');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
// We expect a pre-release, because:
|
||||
// - A tag should not be of "dev" stability.
|
||||
// - After a "stable" release is made, \Drupal::VERSION is incremented,
|
||||
// so there should not be a stable release on that new version.
|
||||
$stability = VersionParser::parseStability(trim($process->getOutput()));
|
||||
$this->assertContains($stability, ['alpha', 'beta', 'RC']);
|
||||
}
|
||||
}
|
||||
return $stability;
|
||||
}
|
||||
|
||||
}
|
||||
671
web/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
Normal file
671
web/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
Normal file
@ -0,0 +1,671 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework;
|
||||
|
||||
use Behat\Mink\Driver\BrowserKitDriver;
|
||||
use Behat\Mink\Mink;
|
||||
use Behat\Mink\Session;
|
||||
use Composer\InstalledVersions;
|
||||
use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
|
||||
use Drupal\Tests\DrupalTestBrowser;
|
||||
use Drupal\Tests\PhpUnitCompatibilityTrait;
|
||||
use Drupal\TestTools\Extension\RequiresComposerTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\Store\FlockStore;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Provides a workspace to test build processes.
|
||||
*
|
||||
* Module tests extending BuildTestBase must exist in the
|
||||
* Drupal\Tests\your_module\Build namespace and live in the
|
||||
* modules/your_module/tests/src/Build directory.
|
||||
*
|
||||
* Tests for core/lib/Drupal classes extending BuildTestBase must exist in the
|
||||
* \Drupal\BuildTests namespace and live in the core/tests/Drupal/BuildTests
|
||||
* directory.
|
||||
*
|
||||
* If you need to build a file system and then run a command from the command
|
||||
* line then this is the test framework for you.
|
||||
*
|
||||
* Tests using this interface run in separate processes.
|
||||
*
|
||||
* Tests can perform HTTP requests against the assembled codebase.
|
||||
*
|
||||
* The results of these HTTP requests can be asserted using Mink.
|
||||
*
|
||||
* This framework does not use the same Mink extensions as BrowserTestBase.
|
||||
*
|
||||
* Features:
|
||||
* - Provide complete isolation between the test runner and the site under test.
|
||||
* - Provide a workspace where filesystem build processes can be performed.
|
||||
* - Allow for the use of PHP's build-in HTTP server to send requests to the
|
||||
* site built using the filesystem.
|
||||
* - Allow for commands and HTTP requests to be made to different subdirectories
|
||||
* of the workspace filesystem, to facilitate comparison between different
|
||||
* build results, and to support Composer builds which have an alternate
|
||||
* docroot.
|
||||
* - Provide as little framework as possible. Convenience methods should be
|
||||
* built into the test, or abstract base classes.
|
||||
* - Allow parallel testing, using random/unique port numbers for different HTTP
|
||||
* servers.
|
||||
*
|
||||
* We don't use UiHelperInterface because it is too tightly integrated to
|
||||
* Drupal.
|
||||
*/
|
||||
abstract class BuildTestBase extends TestCase {
|
||||
|
||||
use RequiresComposerTrait;
|
||||
use PhpUnitCompatibilityTrait;
|
||||
|
||||
/**
|
||||
* The working directory where this test will manipulate files.
|
||||
*
|
||||
* Use getWorkspaceDirectory() to access this information.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @see self::getWorkspaceDirectory()
|
||||
*/
|
||||
private $workspaceDir;
|
||||
|
||||
/**
|
||||
* The process that's running the HTTP server.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see self::standUpServer()
|
||||
* @see self::stopServer()
|
||||
*/
|
||||
private $serverProcess = NULL;
|
||||
|
||||
/**
|
||||
* Default to destroying build artifacts after a test finishes.
|
||||
*
|
||||
* Mainly useful for debugging.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $destroyBuild = TRUE;
|
||||
|
||||
/**
|
||||
* The docroot for the server process.
|
||||
*
|
||||
* This stores the last docroot directory used to start the server process. We
|
||||
* keep this information so we can restart the server if the desired docroot
|
||||
* changes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $serverDocroot = NULL;
|
||||
|
||||
/**
|
||||
* Our native host name, used by PHP when it starts up the server.
|
||||
*
|
||||
* Requests should always be made to 'localhost', and not this IP address.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $hostName = '127.0.0.1';
|
||||
|
||||
/**
|
||||
* Port that will be tested.
|
||||
*
|
||||
* Generated internally. Use getPortNumber().
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $hostPort;
|
||||
|
||||
/**
|
||||
* A list of ports used by the test.
|
||||
*
|
||||
* Prevent the same process finding the same port by storing a list of ports
|
||||
* already discovered. This also stores locks so they are not released until
|
||||
* the test class is torn down.
|
||||
*
|
||||
* @var \Symfony\Component\Lock\LockInterface[]
|
||||
*/
|
||||
private $portLocks = [];
|
||||
|
||||
/**
|
||||
* The Mink session manager.
|
||||
*
|
||||
* @var \Behat\Mink\Mink
|
||||
*/
|
||||
private $mink;
|
||||
|
||||
/**
|
||||
* The most recent command process.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*
|
||||
* @see ::executeCommand()
|
||||
*/
|
||||
private $commandProcess;
|
||||
|
||||
/**
|
||||
* The PHP executable finder.
|
||||
*
|
||||
* @var \Symfony\Component\Process\PhpExecutableFinder
|
||||
*/
|
||||
private PhpExecutableFinder $phpFinder;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->phpFinder = new PhpExecutableFinder();
|
||||
// Set up the workspace directory.
|
||||
// @todo Glean working directory from env vars, etc.
|
||||
$fs = new SymfonyFilesystem();
|
||||
$this->workspaceDir = $fs->tempnam(DrupalFilesystem::getOsTemporaryDirectory(), '/build_workspace_' . md5($this->name() . microtime(TRUE)));
|
||||
$fs->remove($this->workspaceDir);
|
||||
$fs->mkdir($this->workspaceDir);
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
parent::tearDown();
|
||||
|
||||
$this->stopServer();
|
||||
foreach ($this->portLocks as $lock) {
|
||||
$lock->release();
|
||||
}
|
||||
$ws = $this->getWorkspaceDirectory();
|
||||
$fs = new SymfonyFilesystem();
|
||||
if ($this->destroyBuild && $fs->exists($ws)) {
|
||||
// Filter out symlinks as chmod cannot alter them.
|
||||
$finder = new Finder();
|
||||
$finder->in($ws)
|
||||
->directories()
|
||||
->ignoreVCS(FALSE)
|
||||
->ignoreDotFiles(FALSE)
|
||||
// Composer script is a symlink and fails chmod. Ignore it.
|
||||
->notPath('/^vendor\/bin\/composer$/');
|
||||
$fs->chmod($finder->getIterator(), 0775, 0000);
|
||||
$fs->remove($ws);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the working directory within the workspace, creating if necessary.
|
||||
*
|
||||
* @param string $working_dir
|
||||
* The path within the workspace directory.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the working directory within the workspace directory.
|
||||
*/
|
||||
protected function getWorkingPath($working_dir = NULL) {
|
||||
$full_path = $this->getWorkspaceDirectory();
|
||||
if ($working_dir) {
|
||||
$full_path .= '/' . $working_dir;
|
||||
}
|
||||
if (!file_exists($full_path)) {
|
||||
$fs = new SymfonyFilesystem();
|
||||
$fs->mkdir($full_path);
|
||||
}
|
||||
return $full_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the Mink session manager.
|
||||
*
|
||||
* @return \Behat\Mink\Session
|
||||
* The Mink session.
|
||||
*/
|
||||
protected function initMink() {
|
||||
$client = new DrupalTestBrowser();
|
||||
$client->followMetaRefresh(TRUE);
|
||||
$driver = new BrowserKitDriver($client);
|
||||
$session = new Session($driver);
|
||||
$this->mink = new Mink();
|
||||
$this->mink->registerSession('default', $session);
|
||||
$this->mink->setDefaultSessionName('default');
|
||||
$session->start();
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Mink instance.
|
||||
*
|
||||
* Use the Mink object to perform assertions against the content returned by a
|
||||
* request.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object.
|
||||
*/
|
||||
public function getMink() {
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full path to the workspace where this test can build.
|
||||
*
|
||||
* This is often a directory within the system's temporary directory.
|
||||
*
|
||||
* @return string
|
||||
* Full path to the workspace where this test can build.
|
||||
*/
|
||||
public function getWorkspaceDirectory() {
|
||||
return $this->workspaceDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the error output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the error output of the command.
|
||||
*/
|
||||
public function assertErrorOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert text is not present in the error output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect not to find in the error output of the command.
|
||||
*/
|
||||
public function assertErrorOutputNotContains($expected) {
|
||||
$this->assertStringNotContainsString($expected, $this->commandProcess->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that text is present in the output of the most recent command.
|
||||
*
|
||||
* @param string $expected
|
||||
* Text we expect to find in the output of the command.
|
||||
*/
|
||||
public function assertCommandOutputContains($expected) {
|
||||
$this->assertStringContainsString($expected, $this->commandProcess->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command ran without error.
|
||||
*
|
||||
* This assertion checks whether the last command returned an exit code of 0.
|
||||
*
|
||||
* If you need to assert a different exit code, then you can use
|
||||
* executeCommand() and perform a different assertion on the process object.
|
||||
*/
|
||||
public function assertCommandSuccessful() {
|
||||
return $this->assertCommandExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the last command returned the specified exit code.
|
||||
*
|
||||
* @param int $expected_code
|
||||
* The expected process exit code.
|
||||
*/
|
||||
public function assertCommandExitCode($expected_code) {
|
||||
$this->assertEquals($expected_code, $this->commandProcess->getExitCode(),
|
||||
'COMMAND: ' . $this->commandProcess->getCommandLine() . "\n" .
|
||||
'OUTPUT: ' . $this->commandProcess->getOutput() . "\n" .
|
||||
'ERROR: ' . $this->commandProcess->getErrorOutput() . "\n"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command.
|
||||
*
|
||||
* @param string $command_line
|
||||
* A command line to run in an isolated process.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The process object.
|
||||
*/
|
||||
public function executeCommand($command_line, $working_dir = NULL) {
|
||||
$this->commandProcess = Process::fromShellCommandline($command_line);
|
||||
$this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir))
|
||||
->setTimeout(360)
|
||||
->setIdleTimeout(360);
|
||||
$this->commandProcess->run();
|
||||
return $this->commandProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to assert that the last visit was a Drupal site.
|
||||
*
|
||||
* This method asserts that the X-Generator header shows that the site is a
|
||||
* Drupal site.
|
||||
*/
|
||||
public function assertDrupalVisit() {
|
||||
$this->getMink()->assertSession()->responseHeaderMatches('X-Generator', '/Drupal \d+ \(https:\/\/www.drupal.org\)/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a URI on the HTTP server.
|
||||
*
|
||||
* The concept here is that there could be multiple potential docroots in the
|
||||
* workspace, so you can use whichever ones you want.
|
||||
*
|
||||
* @param string $request_uri
|
||||
* (optional) The non-host part of the URL. Example: /some/path?foo=bar.
|
||||
* Defaults to visiting the homepage.
|
||||
* @param string $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* be the docroot for the request. Defaults to the workspace directory.
|
||||
*
|
||||
* @return \Behat\Mink\Mink
|
||||
* The Mink object. Perform assertions against this.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* Thrown when $request_uri does not start with a slash.
|
||||
*/
|
||||
public function visit($request_uri = '/', $working_dir = NULL) {
|
||||
if ($request_uri[0] !== '/') {
|
||||
throw new \InvalidArgumentException('URI: ' . $request_uri . ' must be relative. Example: /some/path?foo=bar');
|
||||
}
|
||||
// Try to make a server.
|
||||
$this->standUpServer($working_dir);
|
||||
|
||||
$request = 'http://localhost:' . $this->getPortNumber() . $request_uri;
|
||||
$this->mink->getSession()->visit($request);
|
||||
return $this->mink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a local test server using PHP's internal HTTP server.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace file system. Defaults
|
||||
* to the workspace directory.
|
||||
*/
|
||||
protected function standUpServer($working_dir = NULL) {
|
||||
// If the user wants to test a new docroot, we have to shut down the old
|
||||
// server process and generate a new port number.
|
||||
if ($working_dir !== $this->serverDocroot && !empty($this->serverProcess)) {
|
||||
$this->stopServer();
|
||||
}
|
||||
// If there's not a server at this point, make one.
|
||||
if (!$this->serverProcess || $this->serverProcess->isTerminated()) {
|
||||
$this->serverProcess = $this->instantiateServer($this->getPortNumber(), $working_dir);
|
||||
if ($this->serverProcess) {
|
||||
$this->serverDocroot = $working_dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the work of making a server process.
|
||||
*
|
||||
* Test authors should call visit() or assertVisit() instead.
|
||||
*
|
||||
* When initializing the server, if '.ht.router.php' exists in the root, it is
|
||||
* leveraged. If testing with a version of Drupal before 8.5.x., this file
|
||||
* does not exist.
|
||||
*
|
||||
* @param int $port
|
||||
* The port number for the server.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Server docroot relative to the workspace filesystem. Defaults
|
||||
* to the workspace directory.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The server process.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown if we were unable to start a web server.
|
||||
*/
|
||||
protected function instantiateServer($port, $working_dir = NULL) {
|
||||
$finder = new PhpExecutableFinder();
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
$server = [
|
||||
$finder->find(),
|
||||
'-S',
|
||||
self::$hostName . ':' . $port,
|
||||
'-t',
|
||||
$working_path,
|
||||
];
|
||||
if (file_exists($working_path . DIRECTORY_SEPARATOR . '.ht.router.php')) {
|
||||
$server[] = $working_path . DIRECTORY_SEPARATOR . '.ht.router.php';
|
||||
}
|
||||
$ps = new Process($server, $working_path);
|
||||
$ps->setIdleTimeout(30)
|
||||
->setTimeout(30)
|
||||
->start();
|
||||
// Wait until the web server has started. It is started if the port is no
|
||||
// longer available.
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
usleep(100000);
|
||||
if (!$this->checkPortIsAvailable($port)) {
|
||||
return $ps;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(sprintf("Unable to start the web server.\nCMD: %s \nCODE: %d\nSTATUS: %s\nOUTPUT:\n%s\n\nERROR OUTPUT:\n%s", $ps->getCommandLine(), $ps->getExitCode(), $ps->getStatus(), $ps->getOutput(), $ps->getErrorOutput()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server, zero out all necessary variables.
|
||||
*/
|
||||
protected function stopServer() {
|
||||
if (!empty($this->serverProcess)) {
|
||||
$this->serverProcess->stop();
|
||||
}
|
||||
$this->serverProcess = NULL;
|
||||
$this->serverDocroot = NULL;
|
||||
$this->hostPort = NULL;
|
||||
$this->initMink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover an available port number.
|
||||
*
|
||||
* @return int
|
||||
* The available port number that we discovered.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* Thrown when there are no available ports within the range.
|
||||
*/
|
||||
protected function findAvailablePort(): int {
|
||||
$store = new FlockStore(DrupalFilesystem::getOsTemporaryDirectory());
|
||||
$lock_factory = new LockFactory($store);
|
||||
|
||||
$counter = 100;
|
||||
while ($counter--) {
|
||||
// Limit to 9999 as higher ports cause random fails on DrupalCI.
|
||||
$port = random_int(1024, 9999);
|
||||
|
||||
if (isset($this->portLocks[$port])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take a lock so that no other process can use the same port number even
|
||||
// if the server is yet to start.
|
||||
$lock = $lock_factory->createLock('drupal-build-test-port-' . $port);
|
||||
if ($lock->acquire()) {
|
||||
if ($this->checkPortIsAvailable($port)) {
|
||||
$this->portLocks[$port] = $lock;
|
||||
return $port;
|
||||
}
|
||||
else {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to find a port available to run the web server.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a port is available.
|
||||
*
|
||||
* @param int $port
|
||||
* A number between 1024 and 65536.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the port is available, FALSE otherwise.
|
||||
*/
|
||||
protected function checkPortIsAvailable($port): bool {
|
||||
$fp = @fsockopen(self::$hostName, $port, $errno, $errstr, 1);
|
||||
// If fsockopen() fails to connect, probably nothing is listening.
|
||||
// It could be a firewall but that's impossible to detect, so as a
|
||||
// best guess let's return it as available.
|
||||
if ($fp === FALSE) {
|
||||
return TRUE;
|
||||
}
|
||||
else {
|
||||
fclose($fp);
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the port number for requests.
|
||||
*
|
||||
* Test should never call this. Used by standUpServer().
|
||||
*
|
||||
* @return int
|
||||
* The port number.
|
||||
*/
|
||||
protected function getPortNumber(): int {
|
||||
if (empty($this->hostPort)) {
|
||||
$this->hostPort = $this->findAvailablePort();
|
||||
}
|
||||
return $this->hostPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current working codebase into a workspace.
|
||||
*
|
||||
* Use this method to copy the current codebase, including any patched
|
||||
* changes, into the workspace.
|
||||
*
|
||||
* By default, the copy will exclude site-specific and build-related files and
|
||||
* directories. Use the $iterator parameter to override this behavior.
|
||||
*
|
||||
* @param \Iterator|null $iterator
|
||||
* (optional) An iterator of all the files to copy. Default behavior is to
|
||||
* exclude site-specific directories and files.
|
||||
* @param string|null $working_dir
|
||||
* (optional) Relative path within the test workspace file system that will
|
||||
* contain the copy of the codebase. Defaults to the workspace directory.
|
||||
*/
|
||||
public function copyCodebase(?\Iterator $iterator = NULL, $working_dir = NULL) {
|
||||
$working_path = $this->getWorkingPath($working_dir);
|
||||
|
||||
if ($iterator === NULL) {
|
||||
$iterator = $this->getCodebaseFinder()->getIterator();
|
||||
}
|
||||
|
||||
$fs = new SymfonyFilesystem();
|
||||
$options = ['override' => TRUE, 'delete' => FALSE];
|
||||
$fs->mirror($this->getComposerRoot(), $working_path, $iterator, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a default Finder object for a Drupal codebase.
|
||||
*
|
||||
* This method can be used two ways:
|
||||
* - Override this method and provide your own default Finder object for
|
||||
* copyCodebase().
|
||||
* - Call the method to get a default Finder object which can then be
|
||||
* modified for other purposes.
|
||||
*
|
||||
* Note that the vendor directory is deliberately not included in the
|
||||
* directory exclusions here, so that packages are copied and composer does
|
||||
* not attempt to download them from packagist/github during test runs.
|
||||
*
|
||||
* @return \Symfony\Component\Finder\Finder
|
||||
* A Finder object ready to iterate over core codebase.
|
||||
*/
|
||||
public function getCodebaseFinder() {
|
||||
$drupal_root = $this->getWorkingPathDrupalRoot() ?? '';
|
||||
$finder = new Finder();
|
||||
$finder->files()
|
||||
->followLinks()
|
||||
->ignoreUnreadableDirs()
|
||||
->in($this->getComposerRoot())
|
||||
->notPath("#^{$drupal_root}sites/default/files#")
|
||||
->notPath("#^{$drupal_root}sites/simpletest#")
|
||||
->notPath("#^{$drupal_root}core/node_modules#")
|
||||
->notPath("#^{$drupal_root}sites/default/settings\..*php#")
|
||||
->ignoreDotFiles(FALSE)
|
||||
->ignoreVCS(FALSE);
|
||||
return $finder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root path of this Drupal codebase.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the root of this Drupal codebase.
|
||||
*/
|
||||
public function getDrupalRoot() {
|
||||
return self::getDrupalRootStatic();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root path of this Drupal codebase.
|
||||
*
|
||||
* @return string
|
||||
* The full path to the root of this Drupal codebase.
|
||||
*/
|
||||
public static function getDrupalRootStatic() {
|
||||
// Given this code is in the drupal/core package, $core cannot be NULL.
|
||||
/** @var string $core */
|
||||
$core = InstalledVersions::getInstallPath('drupal/core');
|
||||
return realpath(dirname($core));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the Composer root directory.
|
||||
*
|
||||
* @return string
|
||||
* The absolute path to the Composer root directory.
|
||||
*/
|
||||
public function getComposerRoot(): string {
|
||||
$root = InstalledVersions::getRootPackage();
|
||||
return realpath($root['install_path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to Drupal root in the workspace directory.
|
||||
*
|
||||
* @return string
|
||||
* The absolute path to the Drupal root directory in the workspace.
|
||||
*/
|
||||
public function getWorkspaceDrupalRoot(): string {
|
||||
$dir = $this->getWorkspaceDirectory();
|
||||
$drupal_root = $this->getWorkingPathDrupalRoot();
|
||||
if ($drupal_root !== NULL) {
|
||||
$dir = $dir . DIRECTORY_SEPARATOR . $drupal_root;
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the working path for Drupal core.
|
||||
*
|
||||
* @return string|null
|
||||
* The relative path to Drupal's root directory or NULL if it is the same
|
||||
* as the composer root directory.
|
||||
*/
|
||||
public function getWorkingPathDrupalRoot(): ?string {
|
||||
$composer_root = $this->getComposerRoot();
|
||||
$drupal_root = $this->getDrupalRoot();
|
||||
if ($composer_root === $drupal_root) {
|
||||
return NULL;
|
||||
}
|
||||
return (new SymfonyFilesystem())->makePathRelative($drupal_root, $composer_root);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use org\bovigo\vfs\vfsStream;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
*/
|
||||
class BuildTestTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* Ensure that workspaces work.
|
||||
*/
|
||||
public function testWorkspace(): void {
|
||||
$test_directory = 'test_directory';
|
||||
|
||||
// Execute an empty command through the shell to build out a working
|
||||
// directory.
|
||||
$process = $this->executeCommand('', $test_directory);
|
||||
$this->assertCommandSuccessful();
|
||||
|
||||
// Assert that our working directory exists and is in use by the process.
|
||||
$workspace = $this->getWorkspaceDirectory();
|
||||
$working_path = $workspace . '/' . $test_directory;
|
||||
$this->assertDirectoryExists($working_path);
|
||||
$this->assertEquals($working_path, $process->getWorkingDirectory());
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebase(): void {
|
||||
$test_directory = 'copied_codebase';
|
||||
$this->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $this->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
$files = [
|
||||
'autoload.php',
|
||||
'composer.json',
|
||||
'index.php',
|
||||
'README.md',
|
||||
'.git',
|
||||
'.ht.router.php',
|
||||
];
|
||||
foreach ($files as $file) {
|
||||
$this->assertFileExists($full_path . '/' . $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we're not copying directories we wish to exclude.
|
||||
*
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebaseExclude(): void {
|
||||
// Create a virtual file system containing items that should be
|
||||
// excluded. Exception being modules directory.
|
||||
vfsStream::setup('drupal', NULL, [
|
||||
'sites' => [
|
||||
'default' => [
|
||||
'files' => [
|
||||
'a_file.txt' => 'some file.',
|
||||
],
|
||||
'settings.php' => '<?php $settings = stuff;',
|
||||
'settings.local.php' => '<?php $settings = override;',
|
||||
],
|
||||
'simpletest' => [
|
||||
'simpletest_hash' => [
|
||||
'some_results.xml' => '<xml/>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'my_module' => [
|
||||
'vendor' => [
|
||||
'my_vendor' => [
|
||||
'composer.json' => "{\n}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock BuildTestBase so that it thinks our VFS is the Composer and Drupal
|
||||
// roots.
|
||||
/** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
|
||||
$base = $this->getMockBuilder(BuildTestBase::class)
|
||||
->onlyMethods(['getDrupalRoot', 'getComposerRoot'])
|
||||
->setConstructorArgs(['test'])
|
||||
->getMock();
|
||||
$base->expects($this->exactly(1))
|
||||
->method('getDrupalRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
$base->expects($this->exactly(3))
|
||||
->method('getComposerRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
|
||||
$base->setUp();
|
||||
|
||||
// Perform the copy.
|
||||
$test_directory = 'copied_codebase';
|
||||
$base->copyCodebase(NULL, $test_directory);
|
||||
$full_path = $base->getWorkspaceDirectory() . '/' . $test_directory;
|
||||
|
||||
$this->assertDirectoryExists($full_path);
|
||||
|
||||
// Verify nested vendor directory was not excluded. Then remove it for next
|
||||
// validation.
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'modules/my_module/vendor/my_vendor/composer.json');
|
||||
$file_system = new Filesystem();
|
||||
$file_system->remove($full_path . DIRECTORY_SEPARATOR . 'modules');
|
||||
|
||||
// Use scandir() to determine if our target directory is empty. It should
|
||||
// only contain the system dot directories.
|
||||
$this->assertTrue(
|
||||
($files = @scandir($full_path)) && count($files) <= 2,
|
||||
'Directory is not empty: ' . implode(', ', $files)
|
||||
);
|
||||
|
||||
$base->tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests copying codebase when Drupal and Composer roots are different.
|
||||
*
|
||||
* @covers ::copyCodebase
|
||||
*/
|
||||
public function testCopyCodebaseDocRoot(): void {
|
||||
// Create a virtual file system containing items that should be
|
||||
// excluded. Exception being modules directory.
|
||||
vfsStream::setup('drupal', NULL, [
|
||||
'docroot' => [
|
||||
'sites' => [
|
||||
'default' => [
|
||||
'files' => [
|
||||
'a_file.txt' => 'some file.',
|
||||
],
|
||||
'settings.php' => '<?php $settings = "stuff";',
|
||||
'settings.local.php' => '<?php $settings = "override";',
|
||||
'default.settings.php' => '<?php $settings = "default";',
|
||||
],
|
||||
'simpletest' => [
|
||||
'simpletest_hash' => [
|
||||
'some_results.xml' => '<xml/>',
|
||||
],
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'my_module' => [
|
||||
'vendor' => [
|
||||
'my_vendor' => [
|
||||
'composer.json' => "{\n}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'vendor' => [
|
||||
'test.txt' => 'File exists',
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock BuildTestBase so that it thinks our VFS is the Composer and Drupal
|
||||
// roots.
|
||||
/** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
|
||||
$base = $this->getMockBuilder(BuildTestBase::class)
|
||||
->onlyMethods(['getDrupalRoot', 'getComposerRoot'])
|
||||
->setConstructorArgs(['test'])
|
||||
->getMock();
|
||||
$base->expects($this->exactly(3))
|
||||
->method('getDrupalRoot')
|
||||
->willReturn(vfsStream::url('drupal/docroot'));
|
||||
$base->expects($this->exactly(5))
|
||||
->method('getComposerRoot')
|
||||
->willReturn(vfsStream::url('drupal'));
|
||||
|
||||
$base->setUp();
|
||||
|
||||
// Perform the copy.
|
||||
$base->copyCodebase();
|
||||
$full_path = $base->getWorkspaceDirectory();
|
||||
|
||||
$this->assertDirectoryExists($full_path . '/docroot');
|
||||
|
||||
// Verify expected files exist.
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'docroot/modules/my_module/vendor/my_vendor/composer.json');
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/default.settings.php');
|
||||
$this->assertFileExists($full_path . DIRECTORY_SEPARATOR . 'vendor');
|
||||
|
||||
// Verify expected files do not exist
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/settings.php');
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/settings.local.php');
|
||||
$this->assertFileDoesNotExist($full_path . DIRECTORY_SEPARATOR . 'docroot/sites/default/files');
|
||||
|
||||
// Ensure that the workspace Drupal root is calculated correctly.
|
||||
$this->assertSame($full_path . '/docroot/', $base->getWorkspaceDrupalRoot());
|
||||
$this->assertSame('docroot/', $base->getWorkingPathDrupalRoot());
|
||||
|
||||
$base->tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::findAvailablePort
|
||||
*/
|
||||
public function testPortMany(): void {
|
||||
$iterator = (new Finder())->in($this->getDrupalRoot())
|
||||
->ignoreDotFiles(FALSE)
|
||||
->exclude(['sites/simpletest'])
|
||||
->path('/^.ht.router.php$/')
|
||||
->getIterator();
|
||||
$this->copyCodebase($iterator);
|
||||
/** @var \Symfony\Component\Process\Process[] $processes */
|
||||
$processes = [];
|
||||
$count = 15;
|
||||
for ($i = 0; $i <= $count; $i++) {
|
||||
$port = $this->findAvailablePort();
|
||||
$this->assertArrayNotHasKey($port, $processes, 'Port ' . $port . ' was already in use by a process.');
|
||||
$processes[$port] = $this->instantiateServer($port);
|
||||
$this->assertNotEmpty($processes[$port]);
|
||||
$this->assertTrue($processes[$port]->isRunning(), 'Process on port ' . $port . ' is not still running.');
|
||||
$this->assertFalse($this->checkPortIsAvailable($port));
|
||||
}
|
||||
|
||||
// Clean up after ourselves.
|
||||
foreach ($processes as $process) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers ::standUpServer
|
||||
*/
|
||||
public function testStandUpServer(): void {
|
||||
// Stand up a server with working directory 'first'.
|
||||
$this->standUpServer('first');
|
||||
|
||||
// Get the process object for the server.
|
||||
$ref_process = new \ReflectionProperty(parent::class, 'serverProcess');
|
||||
$first_process = $ref_process->getValue($this);
|
||||
|
||||
// Standing up the server again should not change the server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertSame($first_process, $ref_process->getValue($this));
|
||||
|
||||
// Standing up the server with working directory 'second' should give us a
|
||||
// new server process.
|
||||
$this->standUpServer('second');
|
||||
$this->assertNotSame(
|
||||
$first_process,
|
||||
$second_process = $ref_process->getValue($this)
|
||||
);
|
||||
|
||||
// And even with the original working directory name, we should get a new
|
||||
// server process.
|
||||
$this->standUpServer('first');
|
||||
$this->assertNotSame($first_process, $ref_process->getValue($this));
|
||||
$this->assertNotSame($second_process, $ref_process->getValue($this));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\Framework\Tests;
|
||||
|
||||
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
|
||||
* @group Build
|
||||
* @requires extension pdo_sqlite
|
||||
*/
|
||||
class HtRouterTest extends QuickStartTestBase {
|
||||
|
||||
/**
|
||||
* @covers ::instantiateServer
|
||||
*/
|
||||
public function testHtRouter(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
$this->copyCodebase();
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
|
||||
$this->assertErrorOutputContains('Generating autoload files');
|
||||
$this->installQuickStart('minimal');
|
||||
$this->formLogin($this->adminUsername, $this->adminPassword);
|
||||
$this->visit('/.well-known/change-password');
|
||||
$this->assertDrupalVisit();
|
||||
$url = $this->getMink()->getSession()->getCurrentUrl();
|
||||
$this->assertEquals('http://localhost:' . $this->getPortNumber() . '/user/1/edit', $url);
|
||||
}
|
||||
|
||||
}
|
||||
300
web/core/tests/Drupal/BuildTests/QuickStart/QuickStartTest.php
Normal file
300
web/core/tests/Drupal/BuildTests/QuickStart/QuickStartTest.php
Normal file
@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Core\Test\TestDatabase;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
|
||||
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the quick-start commands.
|
||||
*
|
||||
* These tests are run in a separate process because they load Drupal code via
|
||||
* an include.
|
||||
*/
|
||||
#[Group('Command')]
|
||||
#[PreserveGlobalState(FALSE)]
|
||||
#[RequiresPhpExtension('pdo_sqlite')]
|
||||
#[RunTestsInSeparateProcesses]
|
||||
class QuickStartTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $php;
|
||||
|
||||
/**
|
||||
* A test database object.
|
||||
*
|
||||
* @var \Drupal\Core\Test\TestDatabase
|
||||
*/
|
||||
protected $testDb;
|
||||
|
||||
/**
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $root;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = $php_executable_finder->find();
|
||||
$this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
|
||||
chdir($this->root);
|
||||
if (!is_writable("{$this->root}/sites/simpletest")) {
|
||||
$this->markTestSkipped('This test requires a writable sites/simpletest directory');
|
||||
}
|
||||
// Get a lock and a valid site path.
|
||||
$this->testDb = new TestDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
if ($this->testDb) {
|
||||
$test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
|
||||
if (file_exists($test_site_directory)) {
|
||||
// @todo use the tear down command from
|
||||
// https://www.drupal.org/project/drupal/issues/2926633
|
||||
// Delete test site directory.
|
||||
$this->fileUnmanagedDeleteRecursive($test_site_directory, [
|
||||
BrowserTestBase::class,
|
||||
'filePreDeleteCallback',
|
||||
]);
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start command.
|
||||
*/
|
||||
public function testQuickStartCommand(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site using the standard profile to ensure the one time login
|
||||
// link generation works.
|
||||
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'quick-start',
|
||||
'standard',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
'--suppress-login',
|
||||
];
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->setTimeout(500);
|
||||
$process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
|
||||
// Ensure the command does not trigger any PHP deprecations.
|
||||
$this->assertStringNotContainsString('Deprecated', $process->getErrorOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Stop the web server.
|
||||
$process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start commands.
|
||||
*/
|
||||
public function testQuickStartInstallAndServerCommands(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'install',
|
||||
'minimal',
|
||||
"--password='secret'",
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
];
|
||||
$install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$install_process->setTimeout(500);
|
||||
$result = $install_process->run();
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $install_process->getErrorOutput());
|
||||
$this->assertStringContainsString("Password: 'secret'", $install_process->getOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
// Run the PHP built-in webserver.
|
||||
$server_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'server',
|
||||
'--suppress-login',
|
||||
];
|
||||
$server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$server_process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$server_process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)\/user\/reset\/1\//', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
$this->assertEquals('', $server_process->getErrorOutput());
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $server_process->getOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Try to re-install over the top of an existing site.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'install',
|
||||
'testing',
|
||||
"--site-name='Test another site {$this->testDb->getDatabasePrefix()}'",
|
||||
];
|
||||
$install_process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$install_process->setTimeout(500);
|
||||
$result = $install_process->run();
|
||||
$this->assertStringContainsString('Drupal is already installed.', $install_process->getOutput());
|
||||
$this->assertSame(0, $result);
|
||||
|
||||
// Ensure the site name has not changed.
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
|
||||
// Stop the web server.
|
||||
$server_process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the install command with an invalid profile.
|
||||
*/
|
||||
public function testQuickStartCommandProfileValidation(): void {
|
||||
// Install a site using the standard profile to ensure the one time login
|
||||
// link generation works.
|
||||
$install_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'quick-start',
|
||||
'umami',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}' --suppress-login",
|
||||
];
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->run();
|
||||
$this->assertMatchesRegularExpression("/'umami' is not a valid install profile or recipe\. Did you mean \W*'demo_umami'?/", $process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the server command when there is no installation.
|
||||
*/
|
||||
public function testServerWithNoInstall(): void {
|
||||
$server_command = [
|
||||
$this->php,
|
||||
'core/scripts/drupal',
|
||||
'server',
|
||||
'--suppress-login',
|
||||
];
|
||||
$server_process = new Process($server_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$server_process->run();
|
||||
$this->assertStringContainsString('No installation found. Use the \'install\' command.', $server_process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all files and directories in the specified path recursively.
|
||||
*
|
||||
* Note this method has no dependencies on Drupal core to ensure that the
|
||||
* test site can be torn down even if something in the test site is broken.
|
||||
*
|
||||
* @param string $path
|
||||
* A string containing either a URI or a file or directory path.
|
||||
* @param callable $callback
|
||||
* (optional) Callback function to run on each file prior to deleting it and
|
||||
* on each directory prior to traversing it. For example, can be used to
|
||||
* modify permissions.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE for success or if path does not exist, FALSE in the event of an
|
||||
* error.
|
||||
*
|
||||
* @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
|
||||
*/
|
||||
protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
|
||||
if (isset($callback)) {
|
||||
call_user_func($callback, $path);
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$dir = dir($path);
|
||||
while (($entry = $dir->read()) !== FALSE) {
|
||||
if ($entry == '.' || $entry == '..') {
|
||||
continue;
|
||||
}
|
||||
$entry_path = $path . '/' . $entry;
|
||||
$this->fileUnmanagedDeleteRecursive($entry_path, $callback);
|
||||
}
|
||||
$dir->close();
|
||||
|
||||
return rmdir($path);
|
||||
}
|
||||
return unlink($path);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
/**
|
||||
* Helper methods for using the quickstart feature of Drupal.
|
||||
*/
|
||||
abstract class QuickStartTestBase extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* User name of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminUsername;
|
||||
|
||||
/**
|
||||
* Password of the admin account generated during install.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $adminPassword;
|
||||
|
||||
/**
|
||||
* Install a Drupal site using the quick start feature.
|
||||
*
|
||||
* @param string $profile
|
||||
* Drupal profile to install.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory relative to the workspace, within which to
|
||||
* execute the command. Defaults to the workspace directory.
|
||||
*/
|
||||
public function installQuickStart($profile, $working_dir = NULL) {
|
||||
$php_finder = new PhpExecutableFinder();
|
||||
$install_process = $this->executeCommand($php_finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
|
||||
$this->assertCommandOutputContains('Username:');
|
||||
preg_match('/Username: (.+)\vPassword: (.+)/', $install_process->getOutput(), $matches);
|
||||
$this->assertNotEmpty($this->adminUsername = $matches[1]);
|
||||
$this->assertNotEmpty($this->adminPassword = $matches[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that uses Drupal's user/login form to log in.
|
||||
*
|
||||
* @param string $username
|
||||
* Username.
|
||||
* @param string $password
|
||||
* Password.
|
||||
* @param string $working_dir
|
||||
* (optional) A working directory within which to login. Defaults to the
|
||||
* workspace directory.
|
||||
*/
|
||||
public function formLogin($username, $password, $working_dir = NULL) {
|
||||
$this->visit('/user/login', $working_dir);
|
||||
$assert = $this->getMink()->assertSession();
|
||||
$assert->statusCodeEquals(200);
|
||||
$assert->fieldExists('edit-name')->setValue($username);
|
||||
$assert->fieldExists('edit-pass')->setValue($password);
|
||||
$session = $this->getMink()->getSession();
|
||||
$session->getPage()->findButton('Log in')->submit();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\QuickStart;
|
||||
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\Core\Test\TestDatabase;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Cookie\CookieJar;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
|
||||
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Tests the quick-start command with recipes.
|
||||
*
|
||||
* These tests are run in a separate process because they load Drupal code via
|
||||
* an include.
|
||||
*/
|
||||
#[Group('Command')]
|
||||
#[Group('Recipe')]
|
||||
#[PreserveGlobalState(FALSE)]
|
||||
#[RequiresPhpExtension('pdo_sqlite')]
|
||||
#[RunTestsInSeparateProcesses]
|
||||
class RecipeQuickStartTest extends BuildTestBase {
|
||||
|
||||
/**
|
||||
* The PHP executable path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $php;
|
||||
|
||||
/**
|
||||
* A test database object.
|
||||
*
|
||||
* @var \Drupal\Core\Test\TestDatabase
|
||||
*/
|
||||
protected TestDatabase $testDb;
|
||||
|
||||
/**
|
||||
* The Drupal root directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $root;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$php_executable_finder = new PhpExecutableFinder();
|
||||
$this->php = (string) $php_executable_finder->find();
|
||||
$this->root = dirname(substr(__DIR__, 0, -strlen(__NAMESPACE__)), 2);
|
||||
if (!is_writable("{$this->root}/sites/simpletest")) {
|
||||
$this->markTestSkipped('This test requires a writable sites/simpletest directory');
|
||||
}
|
||||
// Get a lock and a valid site path.
|
||||
$this->testDb = new TestDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
if ($this->testDb) {
|
||||
$test_site_directory = $this->root . DIRECTORY_SEPARATOR . $this->testDb->getTestSitePath();
|
||||
if (file_exists($test_site_directory)) {
|
||||
// @todo use the tear down command from
|
||||
// https://www.drupal.org/project/drupal/issues/2926633
|
||||
// Delete test site directory.
|
||||
$this->fileUnmanagedDeleteRecursive($test_site_directory, BrowserTestBase::filePreDeleteCallback(...));
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the quick-start command with a recipe.
|
||||
*/
|
||||
public function testQuickStartRecipeCommand(): void {
|
||||
$sqlite = (string) (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
|
||||
// Install a site using the standard recipe to ensure the one time login
|
||||
// link generation works.
|
||||
|
||||
$script = $this->root . '/core/scripts/drupal';
|
||||
$install_command = [
|
||||
$this->php,
|
||||
$script,
|
||||
'quick-start',
|
||||
'core/recipes/standard',
|
||||
"--site-name='Test site {$this->testDb->getDatabasePrefix()}'",
|
||||
'--suppress-login',
|
||||
];
|
||||
$this->assertFileExists($script, "Install script is found in $script");
|
||||
|
||||
$process = new Process($install_command, NULL, ['DRUPAL_DEV_SITE_PATH' => $this->testDb->getTestSitePath()]);
|
||||
$process->setTimeout(500);
|
||||
$process->start();
|
||||
$guzzle = new Client();
|
||||
$port = FALSE;
|
||||
$process->waitUntil(function ($type, $output) use (&$port) {
|
||||
if (preg_match('/127.0.0.1:(\d+)/', $output, $match)) {
|
||||
$port = $match[1];
|
||||
return TRUE;
|
||||
}
|
||||
});
|
||||
// The progress bar uses STDERR to write messages.
|
||||
$this->assertStringContainsString('Congratulations, you installed Drupal!', $process->getErrorOutput());
|
||||
// Ensure the command does not trigger any PHP deprecations.
|
||||
$this->assertStringNotContainsStringIgnoringCase('deprecated', $process->getErrorOutput());
|
||||
$this->assertNotFalse($port, "Web server running on port $port");
|
||||
|
||||
// Give the server a couple of seconds to be ready.
|
||||
sleep(2);
|
||||
$this->assertStringContainsString("127.0.0.1:$port/user/reset/1/", $process->getOutput());
|
||||
|
||||
// Generate a cookie so we can make a request against the installed site.
|
||||
define('DRUPAL_TEST_IN_CHILD_SITE', FALSE);
|
||||
chmod($this->root . '/' . $this->testDb->getTestSitePath(), 0755);
|
||||
$cookieJar = CookieJar::fromArray([
|
||||
'SIMPLETEST_USER_AGENT' => drupal_generate_test_ua($this->testDb->getDatabasePrefix()),
|
||||
], '127.0.0.1');
|
||||
|
||||
$response = $guzzle->get('http://127.0.0.1:' . $port, ['cookies' => $cookieJar]);
|
||||
$content = (string) $response->getBody();
|
||||
$this->assertStringContainsString('Test site ' . $this->testDb->getDatabasePrefix(), $content);
|
||||
// Test content from Standard front page.
|
||||
$this->assertStringContainsString('Congratulations and welcome to the Drupal community.', $content);
|
||||
|
||||
// Stop the web server.
|
||||
$process->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all files and directories in the specified path recursively.
|
||||
*
|
||||
* Note this method has no dependencies on Drupal core to ensure that the
|
||||
* test site can be torn down even if something in the test site is broken.
|
||||
*
|
||||
* @param string $path
|
||||
* A string containing either a URI or a file or directory path.
|
||||
* @param callable $callback
|
||||
* (optional) Callback function to run on each file prior to deleting it and
|
||||
* on each directory prior to traversing it. For example, can be used to
|
||||
* modify permissions.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE for success or if path does not exist, FALSE in the event of an
|
||||
* error.
|
||||
*
|
||||
* @see \Drupal\Core\File\FileSystemInterface::deleteRecursive()
|
||||
*/
|
||||
protected function fileUnmanagedDeleteRecursive($path, $callback = NULL): bool {
|
||||
if (isset($callback)) {
|
||||
call_user_func($callback, $path);
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$dir = dir($path);
|
||||
assert($dir instanceof \Directory);
|
||||
while (($entry = $dir->read()) !== FALSE) {
|
||||
if ($entry == '.' || $entry == '..') {
|
||||
continue;
|
||||
}
|
||||
$entry_path = $path . '/' . $entry;
|
||||
$this->fileUnmanagedDeleteRecursive($entry_path, $callback);
|
||||
}
|
||||
$dir->close();
|
||||
|
||||
return rmdir($path);
|
||||
}
|
||||
return unlink($path);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\BuildTests\TestSiteApplication;
|
||||
|
||||
use Drupal\BuildTests\Framework\BuildTestBase;
|
||||
use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
/**
|
||||
* @group Build
|
||||
* @group TestSiteApplication
|
||||
*/
|
||||
class InstallTest extends BuildTestBase {
|
||||
|
||||
public function testInstall(): void {
|
||||
$sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
|
||||
if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
|
||||
$this->markTestSkipped();
|
||||
}
|
||||
$this->copyCodebase();
|
||||
$fs = new Filesystem();
|
||||
$fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000);
|
||||
|
||||
// Composer tells you stuff in error output.
|
||||
$this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-interaction');
|
||||
$this->assertErrorOutputContains('Generating autoload files');
|
||||
|
||||
// We have to stand up the server first so we can know the port number to
|
||||
// pass along to the install command.
|
||||
$this->standUpServer();
|
||||
|
||||
$php_finder = new PhpExecutableFinder();
|
||||
$install_command = [
|
||||
$php_finder->find(),
|
||||
'./core/scripts/test-site.php',
|
||||
'install',
|
||||
'--base-url=http://localhost:' . $this->getPortNumber(),
|
||||
'--db-url=sqlite://localhost/foo.sqlite',
|
||||
'--install-profile=minimal',
|
||||
'--json',
|
||||
];
|
||||
$this->assertNotEmpty($output_json = $this->executeCommand(implode(' ', $install_command))->getOutput());
|
||||
$this->assertCommandSuccessful();
|
||||
$connection_details = json_decode($output_json, TRUE);
|
||||
foreach (['db_prefix', 'user_agent', 'site_path'] as $key) {
|
||||
$this->assertArrayHasKey($key, $connection_details);
|
||||
}
|
||||
|
||||
// Visit paths with expectations.
|
||||
$this->visit();
|
||||
$this->assertDrupalVisit();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests Ajax callbacks on FAPI elements.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxCallbacksTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests if Ajax callback works on date element.
|
||||
*/
|
||||
public function testDateAjaxCallback(): void {
|
||||
|
||||
// Test Ajax callback when date changes.
|
||||
$this->drupalGet('ajax_forms_test_ajax_element_form');
|
||||
$this->assertNotEmpty($this->getSession()->getPage()->find('xpath', '//div[@id="ajax_date_value"][text()="No date yet selected"]'));
|
||||
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-date]").val("2016-01-01").trigger("change");');
|
||||
$this->assertNotEmpty($this->assertSession()->waitForElement('xpath', '//div[@id="ajax_date_value"]/div[text()="2016-01-01"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if Ajax callback works on datetime element.
|
||||
*/
|
||||
public function testDateTimeAjaxCallback(): void {
|
||||
|
||||
// Test Ajax callback when datetime changes.
|
||||
$this->drupalGet('ajax_forms_test_ajax_element_form');
|
||||
$this->assertNotEmpty($this->getSession()->getPage()->find('xpath', '//div[@id="ajax_datetime_value"][text()="No datetime selected."]'));
|
||||
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-datetime-date]").val("2016-01-01");');
|
||||
$this->getSession()->executeScript('jQuery("[data-drupal-selector=edit-datetime-time]").val("12:00:00").trigger("change");');
|
||||
$this->assertNotEmpty($this->assertSession()->waitForElement('xpath', '//div[@id="ajax_datetime_value"]/div[text()="2016-01-01 12:00:00"]'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the usage of form caching for AJAX forms.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxFormCacheTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests the usage of form cache for AJAX forms.
|
||||
*/
|
||||
public function testFormCacheUsage(): void {
|
||||
/** @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface $key_value_expirable */
|
||||
$key_value_expirable = \Drupal::service('keyvalue.expirable')->get('form');
|
||||
$this->drupalLogin($this->rootUser);
|
||||
|
||||
// Ensure that the cache is empty.
|
||||
$this->assertCount(0, $key_value_expirable->getAll());
|
||||
|
||||
// Visit an AJAX form that is not cached, 3 times.
|
||||
$uncached_form_url = Url::fromRoute('ajax_forms_test.commands_form');
|
||||
$this->drupalGet($uncached_form_url);
|
||||
$this->drupalGet($uncached_form_url);
|
||||
$this->drupalGet($uncached_form_url);
|
||||
|
||||
// The number of cache entries should not have changed.
|
||||
$this->assertCount(0, $key_value_expirable->getAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests AJAX forms in blocks.
|
||||
*/
|
||||
public function testBlockForms(): void {
|
||||
$this->container->get('module_installer')->install(['block', 'search']);
|
||||
$this->rebuildContainer();
|
||||
$this->drupalLogin($this->rootUser);
|
||||
|
||||
$this->drupalPlaceBlock('search_form_block', ['weight' => -5]);
|
||||
$this->drupalPlaceBlock('ajax_forms_test_block');
|
||||
|
||||
$this->drupalGet('');
|
||||
$session = $this->getSession();
|
||||
|
||||
// Select first option and trigger ajax update.
|
||||
$session->getPage()->selectFieldOption('edit-test1', 'option1');
|
||||
|
||||
// DOM update: The InsertCommand in the AJAX response changes the text
|
||||
// in the option element to 'Option1!!!'.
|
||||
$opt1_selector = $this->assertSession()->waitForElement('css', "select[data-drupal-selector='edit-test1'] option:contains('Option 1!!!')");
|
||||
$this->assertNotEmpty($opt1_selector);
|
||||
$this->assertTrue($opt1_selector->isSelected());
|
||||
|
||||
// Confirm option 3 exists.
|
||||
$page = $session->getPage();
|
||||
$opt3_selector = $page->find('xpath', '//select[@data-drupal-selector="edit-test1"]//option[@value="option3"]');
|
||||
$this->assertNotEmpty($opt3_selector);
|
||||
|
||||
// Confirm success message appears after a submit.
|
||||
$page->findButton('edit-submit')->click();
|
||||
$this->assertSession()->waitForButton('edit-submit');
|
||||
$updated_page = $session->getPage();
|
||||
$updated_page->hasContent('Submission successful.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests AJAX forms on pages with a query string.
|
||||
*/
|
||||
public function testQueryString(): void {
|
||||
$this->container->get('module_installer')->install(['block']);
|
||||
$this->drupalLogin($this->rootUser);
|
||||
|
||||
$this->drupalPlaceBlock('ajax_forms_test_block');
|
||||
|
||||
$url = Url::fromRoute('entity.user.canonical', ['user' => $this->rootUser->id()], ['query' => ['foo' => 'bar']]);
|
||||
$this->drupalGet($url);
|
||||
|
||||
$session = $this->getSession();
|
||||
// Select first option and trigger ajax update.
|
||||
$session->getPage()->selectFieldOption('edit-test1', 'option1');
|
||||
|
||||
// DOM update: The InsertCommand in the AJAX response changes the text
|
||||
// in the option element to 'Option1!!!'.
|
||||
$opt1_selector = $this->assertSession()->waitForElement('css', "option:contains('Option 1!!!')");
|
||||
$this->assertNotEmpty($opt1_selector);
|
||||
|
||||
$url->setOption('query', [
|
||||
'foo' => 'bar',
|
||||
]);
|
||||
$this->assertSession()->addressEquals($url);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the Ajax image buttons work with key press events.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxFormImageButtonTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests image buttons can be operated with the keyboard ENTER key.
|
||||
*/
|
||||
public function testAjaxImageButtonKeypressEnter(): void {
|
||||
// Get a Field UI manage-display page.
|
||||
$this->drupalGet('ajax_forms_image_button_form');
|
||||
$assertSession = $this->assertSession();
|
||||
$session = $this->getSession();
|
||||
|
||||
$button = $session->getPage()->findButton('Edit');
|
||||
$button->keyPress(13);
|
||||
|
||||
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '#ajax-1-more-div'), 'Page updated after image button pressed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests image buttons can be operated with the keyboard SPACE key.
|
||||
*/
|
||||
public function testAjaxImageButtonKeypressSpace(): void {
|
||||
// Get a Field UI manage-display page.
|
||||
$this->drupalGet('ajax_forms_image_button_form');
|
||||
$assertSession = $this->assertSession();
|
||||
$session = $this->getSession();
|
||||
|
||||
$button = $session->getPage()->findButton('Edit');
|
||||
$button->keyPress(32);
|
||||
|
||||
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '#ajax-1-more-div'), 'Page updated after image button pressed');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Performs tests on AJAX forms in cached pages.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxFormPageCacheTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$config = $this->config('system.performance');
|
||||
$config->set('cache.page.max_age', 300);
|
||||
$config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the build id of the current form.
|
||||
*/
|
||||
protected function getFormBuildId() {
|
||||
// Ensure the hidden 'form_build_id' field is unique.
|
||||
$this->assertSession()->elementsCount('xpath', '//input[@name="form_build_id"]', 1);
|
||||
return $this->assertSession()->hiddenFieldExists('form_build_id')->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple form, then submit the form via AJAX to change to it.
|
||||
*/
|
||||
public function testSimpleAJAXFormValue(): void {
|
||||
$this->drupalGet('ajax_forms_test_get_form');
|
||||
$build_id_initial = $this->getFormBuildId();
|
||||
|
||||
// Changing the value of a select input element, triggers an AJAX
|
||||
// request/response. The callback on the form responds with three AJAX
|
||||
// commands:
|
||||
// - UpdateBuildIdCommand
|
||||
// - HtmlCommand
|
||||
// - DataCommand
|
||||
$session = $this->getSession();
|
||||
$session->getPage()->selectFieldOption('select', 'green');
|
||||
|
||||
// Wait for the DOM to update. The HtmlCommand will update
|
||||
// #ajax_selected_color to reflect the color change.
|
||||
$green_span = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('green')");
|
||||
$this->assertNotNull($green_span, 'DOM update: The selected color SPAN is green.');
|
||||
|
||||
// Confirm the operation of the UpdateBuildIdCommand.
|
||||
$build_id_first_ajax = $this->getFormBuildId();
|
||||
$this->assertNotEquals($build_id_initial, $build_id_first_ajax, 'Build id is changed in the form_build_id element on first AJAX submission');
|
||||
|
||||
// Changing the value of a select input element, triggers an AJAX
|
||||
// request/response.
|
||||
$session->getPage()->selectFieldOption('select', 'red');
|
||||
|
||||
// Wait for the DOM to update.
|
||||
$red_span = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('red')");
|
||||
$this->assertNotNull($red_span, 'DOM update: The selected color SPAN is red.');
|
||||
|
||||
// Confirm the operation of the UpdateBuildIdCommand.
|
||||
$build_id_second_ajax = $this->getFormBuildId();
|
||||
$this->assertNotEquals($build_id_first_ajax, $build_id_second_ajax, 'Build id changes on subsequent AJAX submissions');
|
||||
|
||||
// Emulate a push of the reload button and then repeat the test sequence
|
||||
// this time with a page loaded from the cache.
|
||||
$session->reload();
|
||||
$build_id_from_cache_initial = $this->getFormBuildId();
|
||||
$this->assertEquals($build_id_initial, $build_id_from_cache_initial, 'Build id is the same as on the first request');
|
||||
|
||||
// Changing the value of a select input element, triggers an AJAX
|
||||
// request/response.
|
||||
$session->getPage()->selectFieldOption('select', 'green');
|
||||
|
||||
// Wait for the DOM to update.
|
||||
$green_span2 = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('green')");
|
||||
$this->assertNotNull($green_span2, 'DOM update: After reload - the selected color SPAN is green.');
|
||||
|
||||
$build_id_from_cache_first_ajax = $this->getFormBuildId();
|
||||
$this->assertNotEquals($build_id_from_cache_initial, $build_id_from_cache_first_ajax, 'Build id is changed in the DOM on first AJAX submission');
|
||||
$this->assertNotEquals($build_id_first_ajax, $build_id_from_cache_first_ajax, 'Build id from first user is not reused');
|
||||
|
||||
// Changing the value of a select input element, triggers an AJAX
|
||||
// request/response.
|
||||
$session->getPage()->selectFieldOption('select', 'red');
|
||||
|
||||
// Wait for the DOM to update.
|
||||
$red_span2 = $this->assertSession()->waitForElement('css', "#ajax_selected_color:contains('red')");
|
||||
$this->assertNotNull($red_span2, 'DOM update: After reload - the selected color SPAN is red.');
|
||||
|
||||
$build_id_from_cache_second_ajax = $this->getFormBuildId();
|
||||
$this->assertNotEquals($build_id_from_cache_first_ajax, $build_id_from_cache_second_ajax, 'Build id changes on subsequent AJAX submissions');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that updating the text field trigger an AJAX request/response.
|
||||
*
|
||||
* @see \Drupal\system\Tests\Ajax\ElementValidationTest::testAjaxElementValidation()
|
||||
*/
|
||||
public function testAjaxElementValidation(): void {
|
||||
$this->drupalGet('ajax_validation_test');
|
||||
// Changing the value of the textfield will trigger an AJAX
|
||||
// request/response.
|
||||
$field = $this->getSession()->getPage()->findField('driver_text');
|
||||
$field->setValue('some dumb text');
|
||||
$field->blur();
|
||||
|
||||
// When the AJAX command updates the DOM a <ul> unsorted list
|
||||
// "message__list" structure will appear on the page echoing back the
|
||||
// "some dumb text" message.
|
||||
$placeholder = $this->assertSession()->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('some dumb text')");
|
||||
$this->assertNotNull($placeholder, 'Message structure containing input data located.');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests that form elements in groups work correctly with AJAX.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxInGroupTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->drupalLogin($this->drupalCreateUser(['access content']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits forms with select and checkbox elements via Ajax.
|
||||
*/
|
||||
public function testSimpleAjaxFormValue(): void {
|
||||
$this->drupalGet('/ajax_forms_test_get_form');
|
||||
|
||||
$assert_session = $this->assertSession();
|
||||
$assert_session->responseContains('Test group');
|
||||
$assert_session->responseContains('AJAX checkbox in a group');
|
||||
|
||||
$session = $this->getSession();
|
||||
$checkbox_original = $session->getPage()->findField('checkbox_in_group');
|
||||
$this->assertNotNull($checkbox_original, 'The checkbox_in_group is on the page.');
|
||||
$original_id = $checkbox_original->getAttribute('id');
|
||||
|
||||
// Triggers an AJAX request/response.
|
||||
$checkbox_original->check();
|
||||
|
||||
// The response contains a new nested "test group" form element, similar
|
||||
// to the one already in the DOM except for a change in the form build id.
|
||||
$checkbox_new = $assert_session->waitForElement('xpath', "//input[@name='checkbox_in_group' and not(@id='$original_id')]");
|
||||
$this->assertNotNull($checkbox_new, 'DOM update: clicking the checkbox refreshed the checkbox_in_group structure');
|
||||
|
||||
$assert_session->responseContains('Test group');
|
||||
$assert_session->responseContains('AJAX checkbox in a group');
|
||||
$assert_session->responseContains('AJAX checkbox in a nested group');
|
||||
$assert_session->responseContains('Another AJAX checkbox in a nested group');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\Tests\field_ui\Traits\FieldUiTestTrait;
|
||||
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
|
||||
use Drupal\Tests\TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests maintenance message during an AJAX call.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxMaintenanceModeTest extends WebDriverTestBase {
|
||||
|
||||
use FieldUiTestTrait;
|
||||
use FileFieldCreationTrait;
|
||||
use TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* An user with administration permissions.
|
||||
*
|
||||
* @var \Drupal\user\UserInterface
|
||||
*/
|
||||
protected $adminUser;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->adminUser = $this->drupalCreateUser([
|
||||
'access administration pages',
|
||||
'administer site configuration',
|
||||
'access site in maintenance mode',
|
||||
]);
|
||||
$this->drupalLogin($this->adminUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests maintenance message only appears once on an AJAX call.
|
||||
*/
|
||||
public function testAjaxCallMaintenanceMode(): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
\Drupal::state()->set('system.maintenance_mode', TRUE);
|
||||
|
||||
$this->drupalGet('ajax-test/insert-inline-wrapper');
|
||||
$assert_session->pageTextContains('Target inline');
|
||||
$page->clickLink('Link html pre-wrapped-div');
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$this->assertSession()->pageTextContainsOnce('Operating in maintenance mode');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\Component\Utility\UrlHelper;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests AJAX responses.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class AjaxTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
if ($this->name() === 'testAjaxFocus') {
|
||||
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3396536");
|
||||
}
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testAjaxWithAdminRoute(): void {
|
||||
\Drupal::service('theme_installer')->install(['stable9', 'claro']);
|
||||
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
|
||||
$theme_config->set('admin', 'claro');
|
||||
$theme_config->set('default', 'stable9');
|
||||
$theme_config->save();
|
||||
|
||||
$account = $this->drupalCreateUser(['view the administration theme']);
|
||||
$this->drupalLogin($account);
|
||||
|
||||
// First visit the site directly via the URL. This should render it in the
|
||||
// admin theme.
|
||||
$this->drupalGet('admin/ajax-test/theme');
|
||||
$assert = $this->assertSession();
|
||||
$assert->pageTextContains('Current theme: claro');
|
||||
|
||||
// Now click the modal, which should use the front-end theme.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
$assert->pageTextNotContains('Current theme: stable9');
|
||||
$this->clickLink('Link 8 (ajax)');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
|
||||
$assert->pageTextContains('Current theme: stable9');
|
||||
$assert->pageTextNotContains('Current theme: claro');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that AJAX loaded libraries are not retained between requests.
|
||||
*
|
||||
* @see https://www.drupal.org/node/2647916
|
||||
*/
|
||||
public function testDrupalSettingsCachingRegression(): void {
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
$assert = $this->assertSession();
|
||||
$session = $this->getSession();
|
||||
|
||||
// Insert a fake library into the already loaded library settings.
|
||||
$fake_library = 'fakeLibrary/fakeLibrary';
|
||||
$libraries = $session->evaluateScript("drupalSettings.ajaxPageState.libraries");
|
||||
$libraries = UrlHelper::compressQueryParameter(UrlHelper::uncompressQueryParameter($libraries) . ',' . $fake_library);
|
||||
$session->evaluateScript("drupalSettings.ajaxPageState.libraries = '$libraries';");
|
||||
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
|
||||
$libraries = UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']);
|
||||
// Test that the fake library is set.
|
||||
$this->assertStringContainsString($fake_library, $libraries);
|
||||
|
||||
// Click on the AJAX link.
|
||||
$this->clickLink('Link 8 (ajax)');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
|
||||
// Test that the fake library is still set after the AJAX call.
|
||||
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
|
||||
// Test that the fake library is set.
|
||||
$this->assertStringContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
|
||||
|
||||
// Reload the page, this should reset the loaded libraries and remove the
|
||||
// fake library.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
|
||||
$this->assertStringNotContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
|
||||
|
||||
// Click on the AJAX link again, and the libraries should still not contain
|
||||
// the fake library.
|
||||
$this->clickLink('Link 8 (ajax)');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$ajax_page_state = $session->evaluateScript("drupalSettings.ajaxPageState");
|
||||
$this->assertStringNotContainsString($fake_library, UrlHelper::uncompressQueryParameter($ajax_page_state['libraries']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that various AJAX responses with DOM elements are correctly inserted.
|
||||
*
|
||||
* After inserting DOM elements, Drupal JavaScript behaviors should be
|
||||
* reattached and all top-level elements of type Node.ELEMENT_NODE need to be
|
||||
* part of the context.
|
||||
*/
|
||||
public function testInsertAjaxResponse(): void {
|
||||
$render_single_root = [
|
||||
'pre-wrapped-div' => '<div class="pre-wrapped">pre-wrapped<script> var test;</script></div>',
|
||||
'pre-wrapped-span' => '<span class="pre-wrapped">pre-wrapped<script> var test;</script></span>',
|
||||
'pre-wrapped-whitespace' => ' <div class="pre-wrapped-whitespace">pre-wrapped-whitespace</div>' . "\n",
|
||||
'not-wrapped' => 'not-wrapped',
|
||||
'comment-string-not-wrapped' => '<!-- COMMENT -->comment-string-not-wrapped',
|
||||
'comment-not-wrapped' => '<!-- COMMENT --><div class="comment-not-wrapped">comment-not-wrapped</div>',
|
||||
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect x="0" y="0" height="10" width="10" fill="green"></rect></svg>',
|
||||
'empty' => '',
|
||||
];
|
||||
$render_multiple_root_unwrap = [
|
||||
'mixed' => ' foo <!-- COMMENT --> foo bar<div class="a class"><p>some string</p></div> additional not wrapped strings, <!-- ANOTHER COMMENT --> <p>final string</p>',
|
||||
'top-level-only' => '<div>element #1</div><div>element #2</div>',
|
||||
'top-level-only-pre-whitespace' => ' <div>element #1</div><div>element #2</div> ',
|
||||
'top-level-only-middle-whitespace-span' => '<span>element #1</span> <span>element #2</span>',
|
||||
'top-level-only-middle-whitespace-div' => '<div>element #1</div> <div>element #2</div>',
|
||||
];
|
||||
|
||||
// This is temporary behavior for BC reason.
|
||||
$render_multiple_root_wrapper = [];
|
||||
foreach ($render_multiple_root_unwrap as $key => $render) {
|
||||
$render_multiple_root_wrapper["$key--effect"] = '<div>' . $render . '</div>';
|
||||
}
|
||||
|
||||
$expected_renders = array_merge(
|
||||
$render_single_root,
|
||||
$render_multiple_root_wrapper,
|
||||
$render_multiple_root_unwrap
|
||||
);
|
||||
|
||||
// Checking default process of wrapping Ajax content.
|
||||
foreach ($expected_renders as $render_type => $expected) {
|
||||
$this->assertInsert($render_type, $expected);
|
||||
}
|
||||
|
||||
// Checking custom ajaxWrapperMultipleRootElements wrapping.
|
||||
$custom_wrapper_multiple_root = <<<JS
|
||||
(function($, Drupal){
|
||||
Drupal.theme.ajaxWrapperMultipleRootElements = function (elements) {
|
||||
return $('<div class="my-favorite-div"></div>').append(elements);
|
||||
};
|
||||
}(jQuery, Drupal));
|
||||
JS;
|
||||
$expected = '<div class="my-favorite-div"><span>element #1</span> <span>element #2</span></div>';
|
||||
$this->assertInsert('top-level-only-middle-whitespace-span--effect', $expected, $custom_wrapper_multiple_root);
|
||||
|
||||
// Checking custom ajaxWrapperNewContent wrapping.
|
||||
$custom_wrapper_new_content = <<<JS
|
||||
(function($, Drupal){
|
||||
Drupal.theme.ajaxWrapperNewContent = function (elements) {
|
||||
return $('<div class="div-wrapper-forever"></div>').append(elements);
|
||||
};
|
||||
}(jQuery, Drupal));
|
||||
JS;
|
||||
$expected = '<div class="div-wrapper-forever"></div>';
|
||||
$this->assertInsert('empty', $expected, $custom_wrapper_new_content);
|
||||
|
||||
// Checking inserting table elements.
|
||||
$expected = '<tr><td>table-row</td></tr>';
|
||||
$this->drupalGet('ajax-test/insert-table-wrapper');
|
||||
$this->clickLink('Link table-row');
|
||||
$this->assertWaitPageContains('<div class="ajax-target-wrapper"><table><tbody id="ajax-target">' . $expected . '</tbody></table></div>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that jQuery's global Ajax events are triggered at the correct time.
|
||||
*/
|
||||
public function testGlobalEvents(): void {
|
||||
$session = $this->getSession();
|
||||
$assert = $this->assertSession();
|
||||
$expected_event_order = implode('', ['ajaxSuccess', 'ajaxComplete', 'ajaxStop']);
|
||||
|
||||
$this->drupalGet('ajax-test/global-events');
|
||||
|
||||
// Ensure that a non-Drupal Ajax request triggers the expected events, in
|
||||
// the correct order, a single time.
|
||||
$session->executeScript('jQuery.get(Drupal.url("core/COPYRIGHT.txt"))');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order);
|
||||
$assert->elementTextEquals('css', '#test_global_events_log2', $expected_event_order);
|
||||
|
||||
// Ensure that an Ajax request to a Drupal Ajax response, but that was not
|
||||
// initiated with Drupal.Ajax(), triggers the expected events, in the
|
||||
// correct order, a single time. We expect $expected_event_order to appear
|
||||
// twice in each log element, because Drupal Ajax response commands (such
|
||||
// as the one to clear the log element) are only executed for requests
|
||||
// initiated with Drupal.Ajax(), and these elements already contain the
|
||||
// text that was added above.
|
||||
$session->executeScript('jQuery.get(Drupal.url("ajax-test/global-events/clear-log"))');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->elementTextEquals('css', '#test_global_events_log', str_repeat($expected_event_order, 2));
|
||||
$assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 2));
|
||||
|
||||
// Ensure that a Drupal Ajax request triggers the expected events, in the
|
||||
// correct order, a single time.
|
||||
// - We expect the first log element to list the events exactly once,
|
||||
// because the Ajax response clears it, and we expect the events to be
|
||||
// triggered after the commands are executed.
|
||||
// - We expect the second log element to list the events exactly three
|
||||
// times, because it already contains the two from the code that was
|
||||
// already executed above. This additional log element that isn't cleared
|
||||
// by the response's command ensures that the events weren't triggered
|
||||
// additional times before the response commands were executed.
|
||||
$this->click('#test_global_events_drupal_ajax_link');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->elementTextEquals('css', '#test_global_events_log', $expected_event_order);
|
||||
$assert->elementTextEquals('css', '#test_global_events_log2', str_repeat($expected_event_order, 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert insert.
|
||||
*
|
||||
* @param string $render_type
|
||||
* Render type.
|
||||
* @param string $expected
|
||||
* Expected result.
|
||||
* @param string $script
|
||||
* Script for additional theming.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function assertInsert(string $render_type, string $expected, string $script = ''): void {
|
||||
// Check insert to block element.
|
||||
$this->drupalGet('ajax-test/insert-block-wrapper');
|
||||
$this->getSession()->executeScript($script);
|
||||
$this->clickLink("Link html $render_type");
|
||||
$this->assertWaitPageContains('<div class="ajax-target-wrapper"><div id="ajax-target">' . $expected . '</div></div>');
|
||||
|
||||
$this->drupalGet('ajax-test/insert-block-wrapper');
|
||||
$this->getSession()->executeScript($script);
|
||||
$this->clickLink("Link replaceWith $render_type");
|
||||
$this->assertWaitPageContains('<div class="ajax-target-wrapper">' . $expected . '</div>');
|
||||
|
||||
// Check insert to inline element.
|
||||
$this->drupalGet('ajax-test/insert-inline-wrapper');
|
||||
$this->getSession()->executeScript($script);
|
||||
$this->clickLink("Link html $render_type");
|
||||
$this->assertWaitPageContains('<div class="ajax-target-wrapper"><span id="ajax-target-inline">' . $expected . '</span></div>');
|
||||
|
||||
$this->drupalGet('ajax-test/insert-inline-wrapper');
|
||||
$this->getSession()->executeScript($script);
|
||||
$this->clickLink("Link replaceWith $render_type");
|
||||
$this->assertWaitPageContains('<div class="ajax-target-wrapper">' . $expected . '</div>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that page contains an expected value after waiting.
|
||||
*
|
||||
* @param string $expected
|
||||
* A needle text.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertWaitPageContains(string $expected): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$this->assertTrue($page->waitFor(10, function () use ($page, $expected) {
|
||||
// Clear content from empty styles and "processed" classes after effect.
|
||||
$content = str_replace([' class="processed"', ' processed', ' style=""'], '', $page->getContent());
|
||||
return stripos($content, $expected) !== FALSE;
|
||||
}), "Page contains expected value: $expected");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Ajax errors are visible in the UI.
|
||||
*/
|
||||
public function testUiAjaxException(): void {
|
||||
$themes = [
|
||||
'olivero',
|
||||
'claro',
|
||||
'stark',
|
||||
];
|
||||
\Drupal::service('theme_installer')->install($themes);
|
||||
|
||||
foreach ($themes as $theme) {
|
||||
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
|
||||
$theme_config->set('default', $theme);
|
||||
$theme_config->save();
|
||||
\Drupal::service('router.builder')->rebuildIfNeeded();
|
||||
|
||||
$this->drupalGet('ajax-test/exception-link');
|
||||
$page = $this->getSession()->getPage();
|
||||
// We don't want the test to error out because of an expected Javascript
|
||||
// console error.
|
||||
$this->failOnJavascriptConsoleErrors = FALSE;
|
||||
// Click on the AJAX link.
|
||||
$this->clickLink('Ajax Exception');
|
||||
$this->assertSession()
|
||||
->statusMessageContainsAfterWait("Oops, something went wrong. Check your browser's developer console for more details.", 'error');
|
||||
|
||||
if ($theme === 'olivero') {
|
||||
// Check that the message can be closed.
|
||||
$this->click('.messages__close');
|
||||
$this->assertTrue($page->find('css', '.messages--error')
|
||||
->hasClass('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
// This is needed to avoid an unfinished AJAX request error from tearDown()
|
||||
// because this test intentionally does not complete all AJAX requests.
|
||||
$this->getSession()->executeScript("delete window.drupalActiveXhrCount");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests ajax focus handling.
|
||||
*/
|
||||
public function testAjaxFocus(): void {
|
||||
$this->drupalGet('/ajax_forms_test_get_form');
|
||||
|
||||
$this->assertNotNull($select = $this->assertSession()->elementExists('css', '#edit-select'));
|
||||
$select->setValue('green');
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-select', $has_focus_id);
|
||||
|
||||
$this->assertNotNull($checkbox = $this->assertSession()->elementExists('css', '#edit-checkbox'));
|
||||
$checkbox->check();
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-checkbox', $has_focus_id);
|
||||
|
||||
$this->assertNotNull($textfield1 = $this->assertSession()->elementExists('css', '#edit-textfield'));
|
||||
$this->assertNotNull($textfield2 = $this->assertSession()->elementExists('css', '#edit-textfield-2'));
|
||||
$this->assertNotNull($textfield3 = $this->assertSession()->elementExists('css', '#edit-textfield-3'));
|
||||
|
||||
// Test textfield with 'blur' event listener.
|
||||
$textfield1->setValue('Kittens say purr');
|
||||
$textfield2->focus();
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-textfield-2', $has_focus_id);
|
||||
|
||||
// Test textfield with 'change' event listener with refocus-blur set to
|
||||
// FALSE.
|
||||
$textfield2->setValue('Llamas say hi');
|
||||
$textfield3->focus();
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-textfield-2', $has_focus_id);
|
||||
|
||||
// Test textfield with 'change' event.
|
||||
$textfield3->focus();
|
||||
$textfield3->setValue('Wasps buzz');
|
||||
$textfield3->blur();
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-textfield-3', $has_focus_id);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Performs tests on AJAX framework commands.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class CommandsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['node', 'ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests the various Ajax Commands.
|
||||
*/
|
||||
public function testAjaxCommands(): void {
|
||||
$session = $this->getSession();
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
$form_path = 'ajax_forms_test_ajax_commands_form';
|
||||
$web_user = $this->drupalCreateUser(['access content']);
|
||||
$this->drupalLogin($web_user);
|
||||
$this->drupalGet($form_path);
|
||||
|
||||
// Tests the 'add_css' command.
|
||||
$page->pressButton("AJAX 'add_css' command");
|
||||
$this->assertWaitPageContains('my/file.css');
|
||||
$this->assertSession()->elementExists('css', 'link[href="my/file.css"]');
|
||||
$this->assertSession()->elementExists('css', 'link[href="https://example.com/css?family=Open+Sans"]');
|
||||
|
||||
// Tests the 'after' command.
|
||||
$page->pressButton("AJAX 'After': Click to put something after the div");
|
||||
$this->assertWaitPageContains('<div id="after_div">Something can be inserted after this</div>This will be placed after');
|
||||
|
||||
// Tests the 'alert' command.
|
||||
$page->pressButton("AJAX 'Alert': Click to alert");
|
||||
// Wait for the alert to appear.
|
||||
$page->waitFor(10, function () use ($session) {
|
||||
try {
|
||||
$session->getDriver()->getWebDriverSession()->alert()->getText();
|
||||
return TRUE;
|
||||
}
|
||||
catch (\Exception) {
|
||||
return FALSE;
|
||||
}
|
||||
});
|
||||
$alert_text = $this->getSession()->getDriver()->getWebDriverSession()->alert()->getText();
|
||||
$this->assertEquals('Alert', $alert_text);
|
||||
$this->getSession()->getDriver()->getWebDriverSession()->alert()->accept();
|
||||
|
||||
$this->drupalGet($form_path);
|
||||
$page->pressButton("AJAX 'Announce': Click to announce");
|
||||
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="polite" aria-busy="false">Default announcement.</div>');
|
||||
|
||||
$this->drupalGet($form_path);
|
||||
$page->pressButton("AJAX 'Announce': Click to announce with 'polite' priority");
|
||||
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="polite" aria-busy="false">Polite announcement.</div>');
|
||||
|
||||
$this->drupalGet($form_path);
|
||||
$page->pressButton("AJAX 'Announce': Click to announce with 'assertive' priority");
|
||||
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="assertive" aria-busy="false">Assertive announcement.</div>');
|
||||
|
||||
$this->drupalGet($form_path);
|
||||
$page->pressButton("AJAX 'Announce': Click to announce twice");
|
||||
$this->assertWaitPageContains('<div id="drupal-live-announce" class="visually-hidden" aria-live="assertive" aria-busy="false">Assertive announcement.' . "\nAnother announcement.</div>");
|
||||
|
||||
// Tests the 'append' command.
|
||||
$page->pressButton("AJAX 'Append': Click to append something");
|
||||
$this->assertWaitPageContains('<div id="append_div">Append inside this divAppended text</div>');
|
||||
|
||||
// Tests the 'before' command.
|
||||
$page->pressButton("AJAX 'before': Click to put something before the div");
|
||||
$this->assertWaitPageContains('Before text<div id="before_div">Insert something before this.</div>');
|
||||
|
||||
// Tests the 'changed' command.
|
||||
$page->pressButton("AJAX changed: Click to mark div changed.");
|
||||
$this->assertWaitPageContains('<div id="changed_div" class="ajax-changed">');
|
||||
|
||||
// Tests the 'changed' command using the second argument.
|
||||
// Refresh page for testing 'changed' command to same element again.
|
||||
$this->drupalGet($form_path);
|
||||
$page->pressButton("AJAX changed: Click to mark div changed with asterisk.");
|
||||
$this->assertWaitPageContains('<div id="changed_div" class="ajax-changed"> <div id="changed_div_mark_this">This div can be marked as changed or not. <abbr class="ajax-changed" title="Changed">*</abbr> </div></div>');
|
||||
|
||||
// Tests the 'css' command.
|
||||
$page->pressButton("Set the '#box' div to be blue.");
|
||||
$this->assertWaitPageContains('<div id="css_div" style="background-color: blue;">');
|
||||
|
||||
// Tests the 'data' command.
|
||||
$page->pressButton("AJAX data command: Issue command.");
|
||||
$this->assertTrue($page->waitFor(10, function () use ($session) {
|
||||
return 'test_value' === $session->evaluateScript('window.jQuery("#data_div").data("test_key")');
|
||||
}));
|
||||
|
||||
// Tests the 'html' command.
|
||||
$page->pressButton("AJAX html: Replace the HTML in a selector.");
|
||||
$this->assertWaitPageContains('<div id="html_div">replacement text</div>');
|
||||
|
||||
// Tests the 'insert' command.
|
||||
$page->pressButton("AJAX insert: Let client insert based on #ajax['method'].");
|
||||
$this->assertWaitPageContains('<div id="insert_div">insert replacement textOriginal contents</div>');
|
||||
|
||||
// Tests the 'invoke' command.
|
||||
$page->pressButton("AJAX invoke command: Invoke addClass() method.");
|
||||
$this->assertWaitPageContains('<div id="invoke_div" class="error">Original contents</div>');
|
||||
|
||||
// Tests the 'prepend' command.
|
||||
$page->pressButton("AJAX 'prepend': Click to prepend something");
|
||||
$this->assertWaitPageContains('<div id="prepend_div">prepended textSomething will be prepended to this div. </div>');
|
||||
|
||||
// Tests the 'remove' command.
|
||||
$page->pressButton("AJAX 'remove': Click to remove text");
|
||||
$this->assertWaitPageContains('<div id="remove_div"></div>');
|
||||
|
||||
// Tests the 'restripe' command.
|
||||
$page->pressButton("AJAX 'restripe' command");
|
||||
$this->assertWaitPageContains('<tr id="table-first" class="odd"><td>first row</td></tr>');
|
||||
$this->assertWaitPageContains('<tr class="even"><td>second row</td></tr>');
|
||||
|
||||
// Tests the 'settings' command.
|
||||
$test_settings_command = <<<JS
|
||||
Drupal.behaviors.testSettingsCommand = {
|
||||
attach: function (context, settings) {
|
||||
window.jQuery('body').append('<div class="test-settings-command">' + settings.ajax_forms_test.foo + '</div>');
|
||||
}
|
||||
};
|
||||
JS;
|
||||
$session->executeScript($test_settings_command);
|
||||
// @todo Replace after https://www.drupal.org/project/drupal/issues/2616184
|
||||
$session->executeScript('window.jQuery("#edit-settings-command-example").mousedown();');
|
||||
$this->assertWaitPageContains('<div class="test-settings-command">42</div>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that page contains a text after waiting.
|
||||
*
|
||||
* @param string $text
|
||||
* A needle text.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertWaitPageContains(string $text): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$page->waitFor(10, function () use ($page, $text) {
|
||||
return stripos($page->getContent(), $text) !== FALSE;
|
||||
});
|
||||
$this->assertStringContainsString($text, $page->getContent());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\ajax_test\Controller\AjaxTestController;
|
||||
use Drupal\Core\Ajax\OpenModalDialogWithUrl;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
// cspell:ignore testdialog
|
||||
|
||||
/**
|
||||
* Performs tests on opening and manipulating dialogs via AJAX commands.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class DialogTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test', 'ajax_forms_test', 'contact'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests sending non-JS and AJAX requests to open and manipulate modals.
|
||||
*/
|
||||
public function testDialog(): void {
|
||||
$this->drupalLogin($this->drupalCreateUser(['administer contact forms']));
|
||||
// Ensure the elements render without notices or exceptions.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
|
||||
// Set up variables for this test.
|
||||
$dialog_renderable = AjaxTestController::dialogContents();
|
||||
$dialog_contents = \Drupal::service('renderer')->renderRoot($dialog_renderable);
|
||||
|
||||
// Check that requesting a modal dialog without JS goes to a page.
|
||||
$this->drupalGet('ajax-test/dialog-contents');
|
||||
$this->assertSession()->responseContains($dialog_contents);
|
||||
|
||||
// Visit the page containing the many test dialog links.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
|
||||
// Tests a basic modal dialog by verifying the contents of the dialog are as
|
||||
// expected.
|
||||
$this->getSession()->getPage()->clickLink('Link 1 (modal)');
|
||||
|
||||
// Clicking the link triggers an AJAX request/response.
|
||||
// Opens a Dialog panel.
|
||||
$link1_dialog_div = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertEquals('true', $link1_dialog_div->getAttribute('aria-modal'), 'Dialog modal has aria-modal attribute');
|
||||
$this->assertNotNull($link1_dialog_div, 'Link was used to open a dialog ( modal )');
|
||||
|
||||
$link1_modal = $link1_dialog_div->find('css', '#drupal-modal');
|
||||
$this->assertNotNull($link1_modal, 'Link was used to open a dialog ( non-modal )');
|
||||
$this->assertSession()->responseContains($dialog_contents);
|
||||
|
||||
$dialog_title = $link1_dialog_div->find('css', "h1.ui-dialog-title:contains('AJAX Dialog & contents')");
|
||||
$this->assertNotNull($dialog_title);
|
||||
$dialog_title_amp = $link1_dialog_div->find('css', "h1.ui-dialog-title:contains('AJAX Dialog & contents')");
|
||||
$this->assertNull($dialog_title_amp);
|
||||
|
||||
// Close open dialog, return to the dialog links page.
|
||||
$close_button = $link1_dialog_div->findButton('Close');
|
||||
$this->assertNotNull($close_button);
|
||||
$close_button->press();
|
||||
|
||||
// Tests a modal with a dialog-option.
|
||||
// Link 2 is similar to Link 1, except it submits additional width
|
||||
// information which must be echoed in the resulting DOM update.
|
||||
$this->getSession()->getPage()->clickLink('Link 2 (modal)');
|
||||
$dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertNotNull($dialog, 'Link was used to open a dialog ( non-modal, with options )');
|
||||
$style = $dialog->getAttribute('style');
|
||||
$this->assertStringContainsString('width: 400px;', $style, "Modal respected the dialog-options width parameter. Style = $style");
|
||||
|
||||
// Reset: Return to the dialog links page.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
|
||||
// Test a non-modal dialog ( with target ).
|
||||
$this->clickLink('Link 3 (non-modal)');
|
||||
$non_modal_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertNull($non_modal_dialog->getAttribute('aria-modal'), 'Dialog modal has no aria-modal attribute');
|
||||
$this->assertNotNull($non_modal_dialog, 'Link opens a non-modal dialog.');
|
||||
$non_modal_dialog_title = $non_modal_dialog->find('css', "h2.ui-dialog-title:contains('AJAX Dialog & contents')");
|
||||
$this->assertNotNull($non_modal_dialog_title);
|
||||
|
||||
// Tests the dialog contains a target element specified in the AJAX request.
|
||||
$non_modal_dialog->find('css', 'div#ajax-test-dialog-wrapper-1');
|
||||
$this->assertSession()->responseContains($dialog_contents);
|
||||
|
||||
// Reset: Return to the dialog links page.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
|
||||
// Tests a non-modal dialog ( without target ).
|
||||
$this->clickLink('Link 7 (non-modal, no target)');
|
||||
$no_target_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertNotNull($no_target_dialog, 'Link opens a non-modal dialog.');
|
||||
|
||||
$contents_no_target = $no_target_dialog->find('css', 'div.ui-dialog-content');
|
||||
$this->assertNotNull($contents_no_target, 'non-modal dialog opens ( no target ). ');
|
||||
$id = $contents_no_target->getAttribute('id');
|
||||
$partial_match = str_starts_with($id, 'drupal-dialog-ajax-testdialog-contents');
|
||||
$this->assertTrue($partial_match, 'The non-modal ID has the expected prefix.');
|
||||
|
||||
$no_target_button = $no_target_dialog->findButton('Close');
|
||||
$this->assertNotNull($no_target_button, 'Link dialog has a close button');
|
||||
$no_target_button->press();
|
||||
|
||||
$this->getSession()->getPage()->findButton('Button 1 (modal)')->press();
|
||||
$button1_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertNotNull($button1_dialog, 'Button opens a modal dialog.');
|
||||
|
||||
$button1_dialog_content = $button1_dialog->find('css', 'div.ui-dialog-content');
|
||||
$this->assertNotNull($button1_dialog_content, 'Button opens a modal dialog.');
|
||||
|
||||
// Test the HTML escaping of & character.
|
||||
$button1_dialog_title = $button1_dialog->find('css', "h1.ui-dialog-title:contains('AJAX Dialog & contents')");
|
||||
$this->assertNotNull($button1_dialog_title);
|
||||
$button1_dialog_title_amp = $button1_dialog->find('css', "h1.ui-dialog-title:contains('AJAX Dialog & contents')");
|
||||
$this->assertNull($button1_dialog_title_amp);
|
||||
|
||||
// Reset: Close the dialog.
|
||||
$button1_dialog->findButton('Close')->press();
|
||||
|
||||
// Abbreviated test for "normal" dialogs, testing only the difference.
|
||||
$this->getSession()->getPage()->findButton('Button 2 (non-modal)')->press();
|
||||
$button2_dialog = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog-content');
|
||||
$this->assertNotNull($button2_dialog, 'Non-modal content displays as expected.');
|
||||
|
||||
// Use a link to close the panel opened by button 2.
|
||||
$this->getSession()->getPage()->clickLink('Link 4 (close non-modal if open)');
|
||||
|
||||
// Test dialogs opened using OpenModalDialogWithUrl.
|
||||
$this->getSession()->getPage()->findButton('Button 3 (modal from url)')->press();
|
||||
// Check that title was fetched properly.
|
||||
// @see \Drupal\ajax_test\Form\AjaxTestDialogForm::dialog.
|
||||
$form_dialog_title = $this->assertSession()->waitForElement('css', "h1.ui-dialog-title:contains('Ajax Form contents')");
|
||||
$this->assertNotNull($form_dialog_title, 'Dialog form has the expected title.');
|
||||
$button1_dialog->findButton('Close')->press();
|
||||
// Test external URL.
|
||||
$dialog_obj = new OpenModalDialogWithUrl('http://example.com', []);
|
||||
try {
|
||||
$dialog_obj->render();
|
||||
}
|
||||
catch (\LogicException $e) {
|
||||
$this->assertEquals('External URLs are not allowed.', $e->getMessage());
|
||||
}
|
||||
|
||||
// Form modal.
|
||||
$this->clickLink('Link 5 (form)');
|
||||
// Two links have been clicked in succession - This time wait for a change
|
||||
// in the title as the previous closing dialog may temporarily be open.
|
||||
$form_dialog_title = $this->assertSession()->waitForElementVisible('css', "h1.ui-dialog-title:contains('Ajax Form contents')");
|
||||
$this->assertNotNull($form_dialog_title, 'Dialog form has the expected title.');
|
||||
// Locate the newly opened dialog.
|
||||
$form_dialog = $this->getSession()->getPage()->find('css', 'div.ui-dialog');
|
||||
$this->assertNotNull($form_dialog, 'Form dialog is visible');
|
||||
|
||||
$form_contents = $form_dialog->find('css', "p:contains('Ajax Form contents description.')");
|
||||
$this->assertNotNull($form_contents, 'For has the expected text.');
|
||||
$do_it = $form_dialog->findButton('Do it');
|
||||
$this->assertNotNull($do_it, 'The dialog has a "Do it" button.');
|
||||
$preview = $form_dialog->findButton('Preview');
|
||||
$this->assertNotNull($preview, 'The dialog contains a "Preview" button.');
|
||||
|
||||
// Form submit inputs, link buttons, and buttons in dialog are copied to the
|
||||
// dialog buttonpane as buttons. The originals should have their styles set
|
||||
// to display: none.
|
||||
$hidden_buttons = $this->getSession()->getPage()->findAll('css', '.ajax-test-form .button');
|
||||
$this->assertCount(3, $hidden_buttons);
|
||||
$hidden_button_text = [];
|
||||
foreach ($hidden_buttons as $button) {
|
||||
$styles = $button->getAttribute('style');
|
||||
$this->assertStringContainsStringIgnoringCase('display: none;', $styles);
|
||||
$hidden_button_text[] = $button->hasAttribute('value') ? $button->getAttribute('value') : $button->getHtml();
|
||||
}
|
||||
|
||||
// The copied buttons should have the same text as the submit inputs they
|
||||
// were copied from.
|
||||
$moved_to_buttonpane_buttons = $this->getSession()->getPage()->findAll('css', '.ui-dialog-buttonpane button');
|
||||
$this->assertCount(3, $moved_to_buttonpane_buttons);
|
||||
foreach ($moved_to_buttonpane_buttons as $key => $button) {
|
||||
$this->assertEquals($hidden_button_text[$key], $button->getText());
|
||||
}
|
||||
|
||||
// Press buttons in the dialog to ensure there are no AJAX errors.
|
||||
$this->assertSession()->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Hello world');
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_text = $this->getSession()->evaluateScript('document.activeElement.textContent');
|
||||
$this->assertEquals('Do it', $has_focus_text);
|
||||
$this->assertSession()->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Preview');
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$has_focus_text = $this->getSession()->evaluateScript('document.activeElement.textContent');
|
||||
$this->assertEquals('Do it', $has_focus_text);
|
||||
|
||||
// Reset: close the form.
|
||||
$form_dialog->findButton('Close')->press();
|
||||
|
||||
// Non AJAX version of Link 6.
|
||||
$this->drupalGet('admin/structure/contact/add');
|
||||
// Check we get a chunk of the code, we can't test the whole form as form
|
||||
// build id and token with be different.
|
||||
$this->assertSession()->elementExists('xpath', "//form[@id='contact-form-add-form']");
|
||||
|
||||
// Reset: Return to the dialog links page.
|
||||
$this->drupalGet('ajax-test/dialog');
|
||||
|
||||
$this->clickLink('Link 6 (entity form)');
|
||||
$dialog_add = $this->assertSession()->waitForElementVisible('css', 'div.ui-dialog');
|
||||
$this->assertNotNull($dialog_add, 'Form dialog is visible');
|
||||
|
||||
$form_add = $dialog_add->find('css', 'form.contact-form-add-form');
|
||||
$this->assertNotNull($form_add, 'Modal dialog JSON contains entity form.');
|
||||
|
||||
$form_title = $dialog_add->find('css', "h1.ui-dialog-title:contains('Add contact form')");
|
||||
$this->assertNotNull($form_title, 'The add form title is as expected.');
|
||||
|
||||
// Test: dialog link opener with title callback.
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
$this->drupalGet("/ajax-test/link-page-dialog");
|
||||
$page->clickLink('Modal link');
|
||||
$this->assertEquals('Dialog link page title', $assert_session->waitForElement('css', '.ui-dialog-title')->getText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests dialog link opener with different HTTP methods.
|
||||
*/
|
||||
public function testHttpMethod(): void {
|
||||
$assert = $this->assertSession();
|
||||
$script = <<<SCRIPT
|
||||
(function() {
|
||||
return document.querySelector('div[aria-describedby="drupal-modal"]').offsetWidth;
|
||||
}())
|
||||
SCRIPT;
|
||||
|
||||
// Open the modal dialog with POST HTTP method.
|
||||
$this->drupalGet('/ajax-test/http-methods');
|
||||
$this->clickLink('Link');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->pageTextContains('Modal dialog contents');
|
||||
$width = $this->getSession()->getDriver()->evaluateScript($script);
|
||||
// The theme is adding 4px as padding and border on each side.
|
||||
$this->assertSame(808, $width);
|
||||
|
||||
// Switch to GET HTTP method.
|
||||
// @see \Drupal\ajax_test\Controller\AjaxTestController::httpMethods()
|
||||
\Drupal::state()->set('ajax_test.http_method', 'GET');
|
||||
|
||||
// Open the modal dialog with GET HTTP method.
|
||||
$this->drupalGet('/ajax-test/http-methods');
|
||||
$this->clickLink('Link');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->pageTextContains('Modal dialog contents');
|
||||
$width = $this->getSession()->getDriver()->evaluateScript($script);
|
||||
// The theme is adding 4px as padding and border on each side.
|
||||
$this->assertSame(808, $width);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Various tests of AJAX behavior.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class ElementValidationTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tries to post an Ajax change to a form that has a validated element.
|
||||
*
|
||||
* Drupal AJAX commands update the DOM echoing back the validated values in
|
||||
* the form of messages that appear on the page.
|
||||
*/
|
||||
public function testAjaxElementValidation(): void {
|
||||
$this->drupalGet('ajax_validation_test');
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert = $this->assertSession();
|
||||
|
||||
// Partially complete the form with a string.
|
||||
$page->fillField('driver_text', 'some dumb text');
|
||||
// Move focus away from this field to trigger AJAX.
|
||||
$page->findField('spare_required_field')->focus();
|
||||
|
||||
// When the AJAX command updates the DOM a <ul> unsorted list
|
||||
// "message__list" structure will appear on the page echoing back the
|
||||
// "some dumb text" message.
|
||||
$placeholder_text = $assert->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('some dumb text')");
|
||||
$this->assertNotNull($placeholder_text, 'A callback successfully echoed back a string.');
|
||||
|
||||
$this->drupalGet('ajax_validation_test');
|
||||
// Partially complete the form with a number.
|
||||
$page->fillField('driver_number', '12345');
|
||||
$page->findField('spare_required_field')->focus();
|
||||
|
||||
// The AJAX request/response will complete successfully when an
|
||||
// InsertCommand injects a message with a placeholder element into the DOM
|
||||
// with the submitted number.
|
||||
$placeholder_number = $assert->waitForElement('css', "[aria-label='Status message'] > ul > li > em:contains('12345')");
|
||||
$this->assertNotNull($placeholder_number, 'A callback successfully echoed back a number.');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests setting focus via AJAX command.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class FocusFirstCommandTest extends WebDriverTestBase {
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests AjaxFocusFirstCommand on a page.
|
||||
*/
|
||||
public function testFocusFirst(): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$this->drupalGet('ajax-test/focus-first');
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertNotContains($has_focus_id, ['edit-first-input', 'edit-first-container-input']);
|
||||
|
||||
// Confirm that focus does not change if the selector targets a
|
||||
// non-focusable container containing no tabbable elements.
|
||||
$page->pressButton('SelectorNothingTabbable');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-has-nothing-tabbable[data-has-focus]'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-selector-has-nothing-tabbable', $has_focus_id);
|
||||
|
||||
// Confirm that focus does not change if the page has no match for the
|
||||
// provided selector.
|
||||
$page->pressButton('SelectorNotExist');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-selector-does-not-exist[data-has-focus]'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-selector-does-not-exist', $has_focus_id);
|
||||
|
||||
// Confirm focus is moved to first tabbable element in a container.
|
||||
$page->pressButton('focusFirstContainer');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-first-container-input[data-has-focus]'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-first-container-input', $has_focus_id);
|
||||
|
||||
// Confirm focus is moved to first tabbable element in a form.
|
||||
$page->pressButton('focusFirstForm');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#ajax-test-focus-first-command-form #edit-first-input[data-has-focus]'));
|
||||
|
||||
// Confirm the form has more than one input to confirm that focus is moved
|
||||
// to the first tabbable element in the container.
|
||||
$this->assertNotNull($page->find('css', '#ajax-test-focus-first-command-form #edit-second-input'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-first-input', $has_focus_id);
|
||||
|
||||
// Confirm that the selector provided will use the first match in the DOM as
|
||||
// the container.
|
||||
$page->pressButton('SelectorMultipleMatches');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-1[data-has-focus]'));
|
||||
$this->assertNotNull($page->findById('edit-inside-same-selector-container-2'));
|
||||
$this->assertNull($assert_session->waitForElementVisible('css', '#edit-inside-same-selector-container-2[data-has-focus]'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('edit-inside-same-selector-container-1', $has_focus_id);
|
||||
|
||||
// Confirm that if a container has no tabbable children, but is itself
|
||||
// focusable, then that container receives focus.
|
||||
$page->pressButton('focusableContainerNotTabbableChildren');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#focusable-container-without-tabbable-children[data-has-focus]'));
|
||||
$has_focus_id = $this->getSession()->evaluateScript('document.activeElement.id');
|
||||
$this->assertEquals('focusable-container-without-tabbable-children', $has_focus_id);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests that form values are properly delivered to AJAX callbacks.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class FormValuesTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['node', 'ajax_test', 'ajax_forms_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->drupalLogin($this->drupalCreateUser(['access content']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits forms with select and checkbox elements via Ajax.
|
||||
*
|
||||
* @dataProvider formModeProvider
|
||||
*/
|
||||
public function testSimpleAjaxFormValue($form_mode): void {
|
||||
$this->drupalGet('ajax_forms_test_get_form');
|
||||
|
||||
$session = $this->getSession();
|
||||
$assertSession = $this->assertSession();
|
||||
|
||||
// Run the test both in a dialog and not in a dialog.
|
||||
if ($form_mode === 'direct') {
|
||||
$this->drupalGet('ajax_forms_test_get_form');
|
||||
}
|
||||
else {
|
||||
$this->drupalGet('ajax_forms_test_dialog_form_link');
|
||||
$assertSession->waitForElementVisible('css', '[data-once="ajax"]');
|
||||
$this->clickLink("Open form in $form_mode");
|
||||
$this->assertNotEmpty($assertSession->waitForElementVisible('css', '.ui-dialog [data-drupal-selector="edit-select"]'));
|
||||
}
|
||||
|
||||
// Verify form values of a select element.
|
||||
foreach (['green', 'blue', 'red'] as $item) {
|
||||
// Updating the field will trigger an AJAX request/response.
|
||||
$session->getPage()->selectFieldOption('select', $item);
|
||||
|
||||
// The AJAX command in the response will update the DOM.
|
||||
$select = $assertSession->waitForElement('css', "div#ajax_selected_color:contains('$item')");
|
||||
$this->assertNotNull($select, "DataCommand has updated the page with a value of $item.");
|
||||
$condition = "(typeof jQuery !== 'undefined' && jQuery('[data-drupal-selector=\"edit-select\"]').is(':focus'))";
|
||||
$this->assertJsCondition($condition, 5000);
|
||||
}
|
||||
|
||||
// Verify form values of a checkbox element.
|
||||
$session->getPage()->checkField('checkbox');
|
||||
$div0 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('checked')");
|
||||
$this->assertNotNull($div0, 'DataCommand updates the DOM as expected when a checkbox is selected');
|
||||
|
||||
$session->getPage()->uncheckField('checkbox');
|
||||
$div1 = $this->assertSession()->waitForElement('css', "div#ajax_checkbox_value:contains('unchecked')");
|
||||
$this->assertNotNull($div1, 'DataCommand updates the DOM as expected when a checkbox is de-selected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that AJAX elements with invalid callbacks return error code 500.
|
||||
*/
|
||||
public function testSimpleInvalidCallbacksAjaxFormValue(): void {
|
||||
$this->drupalGet('ajax_forms_test_get_form');
|
||||
|
||||
$session = $this->getSession();
|
||||
|
||||
// Ensure the test error log is empty before these tests.
|
||||
$this->assertFileDoesNotExist(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
|
||||
|
||||
// We're going to do some invalid requests. The JavaScript errors thrown
|
||||
// whilst doing so are expected. Do not interpret them as a test failure.
|
||||
$this->failOnJavascriptConsoleErrors = FALSE;
|
||||
|
||||
// We don't need to check for the X-Drupal-Ajax-Token header with these
|
||||
// invalid requests.
|
||||
foreach (['null', 'empty', 'nonexistent'] as $key) {
|
||||
$element_name = 'select_' . $key . '_callback';
|
||||
// Updating the field will trigger an AJAX request/response.
|
||||
$session->getPage()->selectFieldOption($element_name, 'green');
|
||||
|
||||
// The select element is disabled as the AJAX request is issued.
|
||||
$this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:disabled");
|
||||
|
||||
// The select element is enabled as the response is received.
|
||||
$this->assertSession()->waitForElement('css', "select[name=\"$element_name\"]:enabled");
|
||||
// Not using File API, a potential error must trigger a PHP warning, which
|
||||
// should be logged in the error.log.
|
||||
$this->assertFileExists(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
|
||||
$this->assertStringContainsString('"The specified #ajax callback is empty or not callable."', file_get_contents(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log'));
|
||||
// Remove error.log, so we have a clean slate for the next request.
|
||||
unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
|
||||
}
|
||||
// We need to reload the page to kill any unfinished AJAX calls before
|
||||
// tearDown() is called.
|
||||
$this->drupalGet('ajax_forms_test_get_form');
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testSimpleAjaxFormValue.
|
||||
*/
|
||||
public static function formModeProvider() {
|
||||
return [
|
||||
['direct'],
|
||||
['dialog'],
|
||||
['off canvas dialog'],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
/**
|
||||
* Tests adding messages via AJAX command.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class MessageCommandTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['ajax_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests AJAX MessageCommand use in a form.
|
||||
*/
|
||||
public function testMessageCommand(): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$this->drupalGet('ajax-test/message');
|
||||
$page->pressButton('Make Message In Default Location');
|
||||
$this->waitForMessageVisible('I am a message in the default location.');
|
||||
$this->assertAnnounceContains('I am a message in the default location.');
|
||||
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
|
||||
|
||||
$page->pressButton('Make Message In Alternate Location');
|
||||
$this->waitForMessageVisible('I am a message in an alternate location.', '#alternate-message-container');
|
||||
$assert_session->pageTextContains('I am a message in the default location.');
|
||||
$this->assertAnnounceContains('I am a message in an alternate location.');
|
||||
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
|
||||
$assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
|
||||
|
||||
$page->pressButton('Make Warning Message');
|
||||
$this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
|
||||
$assert_session->pageTextNotContains('I am a message in the default location.');
|
||||
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
|
||||
$assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
|
||||
|
||||
$this->drupalGet('ajax-test/message');
|
||||
// Test that by default, previous messages in a location are removed.
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$page->pressButton('Make Message In Default Location');
|
||||
$this->waitForMessageVisible('I am a message in the default location.');
|
||||
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
|
||||
|
||||
$page->pressButton('Make Warning Message');
|
||||
$this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
|
||||
// Test that setting MessageCommand::$option['announce'] => '' suppresses
|
||||
// screen reader announcement.
|
||||
$this->assertAnnounceNotContains('I am a warning message in the default location.');
|
||||
$this->waitForMessageRemoved('I am a message in the default location.');
|
||||
$assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
|
||||
}
|
||||
|
||||
// Test that if MessageCommand::clearPrevious is FALSE, messages will not
|
||||
// be cleared.
|
||||
$this->drupalGet('ajax-test/message');
|
||||
for ($i = 1; $i < 7; $i++) {
|
||||
$page->pressButton('Make Message In Alternate Location');
|
||||
$expected_count = $page->waitFor(10, function () use ($i, $page) {
|
||||
return count($page->findAll('css', '#alternate-message-container .messages')) === $i;
|
||||
});
|
||||
$this->assertTrue($expected_count);
|
||||
$this->assertAnnounceContains('I am a message in an alternate location.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests methods in JsWebAssert related to status messages.
|
||||
*/
|
||||
public function testJsStatusMessageAssertions(): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
$this->drupalGet('ajax-test/message');
|
||||
|
||||
$page->pressButton('Make Message In Default Location');
|
||||
$this->assertSession()->statusMessageContainsAfterWait('I am a message in the default location.');
|
||||
|
||||
$page->pressButton('Make Message In Alternate Location');
|
||||
$this->assertSession()->statusMessageContainsAfterWait('I am a message in an alternate location.', 'status');
|
||||
|
||||
$page->pressButton('Make Warning Message');
|
||||
$this->assertSession()->statusMessageContainsAfterWait('I am a warning message in the default location.', 'warning');
|
||||
|
||||
// Reload and test some negative assertions.
|
||||
$this->drupalGet('ajax-test/message');
|
||||
|
||||
$page->pressButton('Make Message In Default Location');
|
||||
// Use message that is not on page.
|
||||
$this->assertSession()->statusMessageNotContainsAfterWait('This is not a real message');
|
||||
|
||||
$page->pressButton('Make Message In Alternate Location');
|
||||
// Use message that exists but has the wrong type.
|
||||
$this->assertSession()->statusMessageNotContainsAfterWait('I am a message in an alternate location.', 'warning');
|
||||
|
||||
// Test partial match.
|
||||
$page->pressButton('Make Warning Message');
|
||||
$this->assertSession()->statusMessageContainsAfterWait('I am a warning');
|
||||
|
||||
// One more reload to try with different arg combinations.
|
||||
$this->drupalGet('ajax-test/message');
|
||||
|
||||
$page->pressButton('Make Message In Default Location');
|
||||
$this->assertSession()->statusMessageExistsAfterWait();
|
||||
|
||||
$page->pressButton('Make Message In Alternate Location');
|
||||
$this->assertSession()->statusMessageNotExistsAfterWait('error');
|
||||
|
||||
$page->pressButton('Make Warning Message');
|
||||
$this->assertSession()->statusMessageExistsAfterWait('warning');
|
||||
|
||||
// Perform a few assertions that should fail. We can only call
|
||||
// TestCase::expectException() once per test, so we make a few
|
||||
// try/catch blocks. We pass a relatively short timeout because
|
||||
// it is a waste of time to wait 10 seconds in these assertions
|
||||
// that we fully expect to fail.
|
||||
$expected_failure_occurred = FALSE;
|
||||
try {
|
||||
$this->assertSession()->statusMessageContainsAfterWait('Not a real message', NULL, 1000);
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
$expected_failure_occurred = TRUE;
|
||||
}
|
||||
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageContainsAfterWait() did not fail when it should have failed.');
|
||||
|
||||
$expected_failure_occurred = FALSE;
|
||||
try {
|
||||
$this->assertSession()->statusMessageNotContainsAfterWait('I am a warning', NULL, 1000);
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
$expected_failure_occurred = TRUE;
|
||||
}
|
||||
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageNotContainsAfterWait() did not fail when it should have failed.');
|
||||
|
||||
$expected_failure_occurred = FALSE;
|
||||
try {
|
||||
$this->assertSession()->statusMessageExistsAfterWait('error', 1000);
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
$expected_failure_occurred = TRUE;
|
||||
}
|
||||
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageExistsAfterWait() did not fail when it should have failed.');
|
||||
|
||||
$expected_failure_occurred = FALSE;
|
||||
try {
|
||||
$this->assertSession()->statusMessageNotExistsAfterWait('warning', 1000);
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
$expected_failure_occurred = TRUE;
|
||||
}
|
||||
$this->assertTrue($expected_failure_occurred, 'JsWebAssert::statusMessageNotExistsAfterWait() did not fail when it should have failed.');
|
||||
|
||||
// Tests passing a bad status type.
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->assertSession()->statusMessageExistsAfterWait('not a valid type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a message of the expected type appears.
|
||||
*
|
||||
* @param string $message
|
||||
* The expected message.
|
||||
* @param string $selector
|
||||
* The selector for the element in which to check for the expected message.
|
||||
* @param string $type
|
||||
* The expected type.
|
||||
*/
|
||||
protected function waitForMessageVisible($message, $selector = '[data-drupal-messages]', $type = 'status'): void {
|
||||
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a message of the expected type is removed.
|
||||
*
|
||||
* @param string $message
|
||||
* The expected message.
|
||||
* @param string $selector
|
||||
* The selector for the element in which to check for the expected message.
|
||||
* @param string $type
|
||||
* The expected type.
|
||||
*/
|
||||
protected function waitForMessageRemoved($message, $selector = '[data-drupal-messages]', $type = 'status'): void {
|
||||
$this->assertNotEmpty($this->assertSession()->waitForElementRemoved('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for inclusion of text in #drupal-live-announce.
|
||||
*
|
||||
* @param string $expected_message
|
||||
* The text expected to be present in #drupal-live-announce.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertAnnounceContains(string $expected_message): void {
|
||||
$assert_session = $this->assertSession();
|
||||
$this->assertNotEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for absence of the given text from #drupal-live-announce.
|
||||
*
|
||||
* @param string $expected_message
|
||||
* The text expected to be absent from #drupal-live-announce.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertAnnounceNotContains(string $expected_message): void {
|
||||
$assert_session = $this->assertSession();
|
||||
$this->assertEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')", 1000));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests AJAX-enabled forms when multiple instances of the form are on a page.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class MultiFormTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['node', 'form_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Page']);
|
||||
|
||||
// Create a multi-valued field for 'page' nodes to use for Ajax testing.
|
||||
$field_name = 'field_ajax_test';
|
||||
FieldStorageConfig::create([
|
||||
'entity_type' => 'node',
|
||||
'field_name' => $field_name,
|
||||
'type' => 'text',
|
||||
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => 'node',
|
||||
'bundle' => 'page',
|
||||
])->save();
|
||||
\Drupal::service('entity_display.repository')->getFormDisplay('node', 'page', 'default')
|
||||
->setComponent($field_name, ['type' => 'text_textfield'])
|
||||
->save();
|
||||
|
||||
// Log in a user who can create 'page' nodes.
|
||||
$this->drupalLogin($this->drupalCreateUser(['create page content']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that pages with the 'node_page_form' included twice work correctly.
|
||||
*/
|
||||
public function testMultiForm(): void {
|
||||
// HTML IDs for elements within the field are potentially modified with
|
||||
// each Ajax submission, but these variables are stable and help target the
|
||||
// desired elements.
|
||||
$field_name = 'field_ajax_test';
|
||||
|
||||
$form_xpath = '//form[starts-with(@id, "node-page-form")]';
|
||||
$field_xpath = '//div[contains(@class, "field--name-field-ajax-test")]';
|
||||
$button_name = $field_name . '_add_more';
|
||||
$button_xpath_suffix = '//input[@name="' . $button_name . '"]';
|
||||
$field_items_xpath_suffix = '//input[@type="text"]';
|
||||
|
||||
// Ensure the initial page contains both node forms and the correct number
|
||||
// of field items and "add more" button for the multi-valued field within
|
||||
// each form.
|
||||
$this->drupalGet('form-test/two-instances-of-same-form');
|
||||
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
$fields = $page->findAll('xpath', $form_xpath . $field_xpath);
|
||||
$this->assertCount(2, $fields);
|
||||
foreach ($fields as $field) {
|
||||
$this->assertCount(1, $field->findAll('xpath', '.' . $field_items_xpath_suffix), 'Found the correct number of field items on the initial page.');
|
||||
$this->assertNotNull($field->find('xpath', '.' . $button_xpath_suffix), 'Found the "add more" button on the initial page.');
|
||||
}
|
||||
|
||||
$this->assertSession()->pageContainsNoDuplicateId();
|
||||
|
||||
// Submit the "add more" button of each form twice. After each corresponding
|
||||
// page update, ensure the same as above.
|
||||
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
$forms = $page->findAll('xpath', $form_xpath);
|
||||
foreach ($forms as $form) {
|
||||
$button = $form->findButton('Add another item');
|
||||
$this->assertNotNull($button, 'Add Another Item button exists');
|
||||
$button->press();
|
||||
|
||||
// Wait for field to be added with ajax.
|
||||
$this->assertNotEmpty($page->waitFor(10, function () use ($form, $i) {
|
||||
return $form->findField('field_ajax_test[' . ($i + 1) . '][value]');
|
||||
}));
|
||||
|
||||
// After AJAX request and response verify the correct number of text
|
||||
// fields (including title), as well as the "Add another item" button.
|
||||
$this->assertCount($i + 3, $form->findAll('css', 'input[type="text"]'), 'Found the correct number of field items after an AJAX submission.');
|
||||
$this->assertNotEmpty($form->findButton('Add another item'), 'Found the "add more" button after an AJAX submission.');
|
||||
$this->assertSession()->pageContainsNoDuplicateId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Ajax;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\hold_test\HoldTestHelper;
|
||||
|
||||
/**
|
||||
* Tests the throbber.
|
||||
*
|
||||
* @group Ajax
|
||||
*/
|
||||
class ThrobberTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'node',
|
||||
'views',
|
||||
'views_ui',
|
||||
'views_ui_test_field',
|
||||
'hold_test',
|
||||
'block',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests theming throbber element.
|
||||
*/
|
||||
public function testThemingThrobberElement(): void {
|
||||
$session = $this->getSession();
|
||||
$web_assert = $this->assertSession();
|
||||
$page = $session->getPage();
|
||||
$admin_user = $this->drupalCreateUser([
|
||||
'administer views',
|
||||
'administer blocks',
|
||||
]);
|
||||
$this->drupalLogin($admin_user);
|
||||
|
||||
$custom_ajax_progress_indicator_fullscreen = <<<JS
|
||||
Drupal.theme.ajaxProgressIndicatorFullscreen = function () {
|
||||
return '<div class="custom-ajax-progress-fullscreen"></div>';
|
||||
};
|
||||
JS;
|
||||
$custom_ajax_progress_throbber = <<<JS
|
||||
Drupal.theme.ajaxProgressThrobber = function (message) {
|
||||
return '<div class="custom-ajax-progress-throbber"></div>';
|
||||
};
|
||||
JS;
|
||||
$custom_ajax_progress_message = <<<JS
|
||||
Drupal.theme.ajaxProgressMessage = function (message) {
|
||||
return '<div class="custom-ajax-progress-message">Hold door!</div>';
|
||||
};
|
||||
JS;
|
||||
|
||||
$this->drupalGet('admin/structure/views/view/content');
|
||||
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-fullscreen');
|
||||
|
||||
// Test theming fullscreen throbber.
|
||||
$session->executeScript($custom_ajax_progress_indicator_fullscreen);
|
||||
HoldTestHelper::responseHold(TRUE);
|
||||
$page->clickLink('Content: Published (grouped)');
|
||||
$this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-fullscreen'), 'Custom ajaxProgressIndicatorFullscreen.');
|
||||
HoldTestHelper::responseHold(FALSE);
|
||||
$web_assert->assertNoElementAfterWait('css', '.custom-ajax-progress-fullscreen');
|
||||
|
||||
// Test theming throbber message.
|
||||
$web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-add-group"]');
|
||||
$session->executeScript($custom_ajax_progress_message);
|
||||
HoldTestHelper::responseHold(TRUE);
|
||||
$page->pressButton('Add another item');
|
||||
$this->assertNotNull($web_assert->waitForElement('css', '.ajax-progress-throbber .custom-ajax-progress-message'), 'Custom ajaxProgressMessage.');
|
||||
HoldTestHelper::responseHold(FALSE);
|
||||
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-throbber');
|
||||
|
||||
// Test theming throbber.
|
||||
$web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-group-items-3-title"]');
|
||||
$session->executeScript($custom_ajax_progress_throbber);
|
||||
HoldTestHelper::responseHold(TRUE);
|
||||
$page->pressButton('Add another item');
|
||||
$this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-throbber'), 'Custom ajaxProgressThrobber.');
|
||||
HoldTestHelper::responseHold(FALSE);
|
||||
$web_assert->assertNoElementAfterWait('css', '.custom-ajax-progress-throbber');
|
||||
|
||||
// Test progress throbber position on a dropbutton in a table display.
|
||||
$this->drupalGet('/admin/structure/block');
|
||||
$this->clickLink('Place block');
|
||||
$web_assert->assertWaitOnAjaxRequest();
|
||||
$this->assertNotEmpty($web_assert->waitForElementVisible('css', '#drupal-modal'));
|
||||
HoldTestHelper::responseHold(TRUE);
|
||||
$this->clickLink('Place block');
|
||||
$this->assertNotNull($web_assert->waitForElement('xpath', '//div[contains(@class, "dropbutton-wrapper")]/following-sibling::div[contains(@class, "ajax-progress-throbber")]'));
|
||||
HoldTestHelper::responseHold(FALSE);
|
||||
$web_assert->assertNoElementAfterWait('css', '.ajax-progress-throbber');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Tests that unnecessary or untracked XHRs will cause a test failure.
|
||||
*
|
||||
* @group javascript
|
||||
* @group legacy
|
||||
*/
|
||||
class AjaxWaitTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'starterkit_theme';
|
||||
|
||||
/**
|
||||
* Tests that an unnecessary wait triggers an error.
|
||||
*/
|
||||
public function testUnnecessaryWait(): void {
|
||||
$this->drupalGet('user');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('There are no AJAX requests to wait for.');
|
||||
|
||||
$this->assertSession()->assertWaitOnAjaxRequest(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that an untracked XHR triggers an error.
|
||||
*/
|
||||
public function testUntrackedXhr(): void {
|
||||
$this->getSession()->executeScript(<<<JS
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/foobar');
|
||||
xhr.send();
|
||||
JS);
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('0 XHR requests through jQuery, but 1 observed in the browser — this requires js_testing_ajax_request_test.js to be updated.');
|
||||
|
||||
$this->assertSession()->assertExpectedAjaxRequest(1, 500);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
|
||||
/**
|
||||
* Tests if we can execute JavaScript in the browser.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class BrowserWithJavascriptTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['test_page_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
public function testJavascript(): void {
|
||||
$this->drupalGet('<front>');
|
||||
$session = $this->getSession();
|
||||
|
||||
$session->resizeWindow(400, 300);
|
||||
$javascript = <<<JS
|
||||
(function(){
|
||||
var w = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
x = w.innerWidth || e.clientWidth || g.clientWidth,
|
||||
y = w.innerHeight || e.clientHeight|| g.clientHeight;
|
||||
return x == 400 && y == 300;
|
||||
}())
|
||||
JS;
|
||||
$this->assertJsCondition($javascript);
|
||||
|
||||
// Ensure that \Drupal\Tests\UiHelperTrait::isTestUsingGuzzleClient() works
|
||||
// as expected.
|
||||
$this->assertFalse($this->isTestUsingGuzzleClient());
|
||||
}
|
||||
|
||||
public function testAssertJsCondition(): void {
|
||||
$this->drupalGet('<front>');
|
||||
$session = $this->getSession();
|
||||
|
||||
$session->resizeWindow(500, 300);
|
||||
$javascript = <<<JS
|
||||
(function(){
|
||||
var w = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
x = w.innerWidth || e.clientWidth || g.clientWidth,
|
||||
y = w.innerHeight || e.clientHeight|| g.clientHeight;
|
||||
return x == 400 && y == 300;
|
||||
}())
|
||||
JS;
|
||||
|
||||
// We expected the following assertion to fail because the window has been
|
||||
// re-sized to have a width of 500 not 400.
|
||||
$this->expectException(AssertionFailedError::class);
|
||||
$this->assertJsCondition($javascript, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests creating screenshots.
|
||||
*/
|
||||
public function testCreateScreenshot(): void {
|
||||
$this->drupalGet('<front>');
|
||||
$this->createScreenshot('public://screenshot.jpg');
|
||||
$this->assertFileExists('public://screenshot.jpg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests assertEscaped() and assertUnescaped().
|
||||
*
|
||||
* @see \Drupal\Tests\WebAssert::assertNoEscaped()
|
||||
* @see \Drupal\Tests\WebAssert::assertEscaped()
|
||||
*/
|
||||
public function testEscapingAssertions(): void {
|
||||
$assert = $this->assertSession();
|
||||
|
||||
$this->drupalGet('test-escaped-characters');
|
||||
$assert->assertNoEscaped('<div class="escaped">');
|
||||
$assert->responseContains('<div class="escaped">');
|
||||
$assert->assertEscaped('Escaped: <"\'&>');
|
||||
|
||||
$this->drupalGet('test-escaped-script');
|
||||
$assert->assertNoEscaped('<div class="escaped">');
|
||||
$assert->responseContains('<div class="escaped">');
|
||||
$assert->assertEscaped("<script>alert('XSS');alert(\"XSS\");</script>");
|
||||
|
||||
$this->drupalGetWithAlert('test-unescaped-script');
|
||||
$assert->assertNoEscaped('<div class="unescaped">');
|
||||
$assert->responseContains('<div class="unescaped">');
|
||||
$assert->responseContains("<script>alert('Marked safe');alert(\"Marked safe\");</script>");
|
||||
$assert->assertNoEscaped("<script>alert('Marked safe');alert(\"Marked safe\");</script>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a Drupal path or an absolute path.
|
||||
*
|
||||
* @param string|\Drupal\Core\Url $path
|
||||
* Drupal path or URL to load into Mink controlled browser.
|
||||
* @param array $options
|
||||
* (optional) Options to be forwarded to the URL generator.
|
||||
* @param string[] $headers
|
||||
* An array containing additional HTTP request headers, the array keys are
|
||||
* the header names and the array values the header values. This is useful
|
||||
* to set for example the "Accept-Language" header for requesting the page
|
||||
* in a different language. Note that not all headers are supported, for
|
||||
* example the "Accept" header is always overridden by the browser. For
|
||||
* testing REST APIs it is recommended to obtain a separate HTTP client
|
||||
* using getHttpClient() and performing requests that way.
|
||||
*
|
||||
* @return string
|
||||
* The retrieved HTML string, also available as $this->getRawContent()
|
||||
*
|
||||
* @see \Drupal\Tests\BrowserTestBase::getHttpClient()
|
||||
*/
|
||||
protected function drupalGetWithAlert($path, array $options = [], array $headers = []) {
|
||||
$options['absolute'] = TRUE;
|
||||
$url = $this->buildUrl($path, $options);
|
||||
|
||||
$session = $this->getSession();
|
||||
|
||||
$this->prepareRequest();
|
||||
foreach ($headers as $header_name => $header_value) {
|
||||
$session->setRequestHeader($header_name, $header_value);
|
||||
}
|
||||
|
||||
$session->visit($url);
|
||||
|
||||
// There are 2 alerts to accept before we can get the content of the page.
|
||||
$session->getDriver()->getWebdriverSession()->alert()->accept();
|
||||
$session->getDriver()->getWebdriverSession()->alert()->accept();
|
||||
|
||||
$out = $session->getPage()->getContent();
|
||||
|
||||
// Ensure that any changes to variables in the other thread are picked up.
|
||||
$this->refreshVariables();
|
||||
|
||||
// Replace original page output with new output from redirected page(s).
|
||||
if ($new = $this->checkForMetaRefresh()) {
|
||||
$out = $new;
|
||||
// We are finished with all meta refresh redirects, so reset the counter.
|
||||
$this->metaRefreshCount = 0;
|
||||
}
|
||||
|
||||
// Log only for WebDriverTestBase tests because for DrupalTestBrowser we log
|
||||
// with ::getResponseLogHandler.
|
||||
if ($this->htmlOutputEnabled && !$this->isTestUsingGuzzleClient()) {
|
||||
$html_output = 'GET request to: ' . $url .
|
||||
'<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
|
||||
$html_output .= '<hr />' . $out;
|
||||
$html_output .= $this->getHtmlOutputHeaders();
|
||||
$this->htmlOutput($html_output);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Components;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the correct rendering of components.
|
||||
*
|
||||
* @group sdc
|
||||
*/
|
||||
class ComponentRenderTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['system', 'sdc_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'sdc_theme_test';
|
||||
|
||||
/**
|
||||
* Tests that the correct libraries are put on the page using CSS.
|
||||
*
|
||||
* This also covers all the path translations necessary to produce the correct
|
||||
* path to the assets.
|
||||
*/
|
||||
public function testCssLibraryAttachesCorrectly(): void {
|
||||
$build = [
|
||||
'#type' => 'inline_template',
|
||||
'#template' => "{{ include('sdc_theme_test:lib-overrides') }}",
|
||||
];
|
||||
\Drupal::state()->set('sdc_test_component', $build);
|
||||
$this->drupalGet('sdc-test-component');
|
||||
$wrapper = $this->getSession()->getPage()->find('css', '#sdc-wrapper');
|
||||
// Opacity is set to 0 in the CSS file (see another-stylesheet.css).
|
||||
$this->assertFalse($wrapper->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the correct libraries are put on the page using JS.
|
||||
*
|
||||
* This also covers all the path translations necessary to produce the correct
|
||||
* path to the assets.
|
||||
*/
|
||||
public function testJsLibraryAttachesCorrectly(): void {
|
||||
$build = [
|
||||
'#type' => 'inline_template',
|
||||
'#template' => "{{ include('sdc_test:my-button', {
|
||||
text: 'Click'
|
||||
}, with_context = false) }}",
|
||||
];
|
||||
\Drupal::state()->set('sdc_test_component', $build);
|
||||
$this->drupalGet('sdc-test-component');
|
||||
$page = $this->getSession()->getPage();
|
||||
$page->find('css', '[data-component-id="sdc_test:my-button"]')
|
||||
->click();
|
||||
$this->assertSame(
|
||||
'Click power (1)',
|
||||
$page->find('css', '[data-component-id="sdc_test:my-button"]')->getText(),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Test race condition for CSRF tokens for simultaneous requests.
|
||||
*
|
||||
* @group Session
|
||||
*/
|
||||
class CsrfTokenRaceTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['csrf_race_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests race condition for CSRF tokens for simultaneous requests.
|
||||
*/
|
||||
public function testCsrfRace(): void {
|
||||
$user = $this->createUser(['access content']);
|
||||
$this->drupalLogin($user);
|
||||
$this->drupalGet('/csrf_race/test');
|
||||
$script = '';
|
||||
// Delay the request processing of the first request by one second through
|
||||
// the request parameter, which will simulate the concurrent processing
|
||||
// of both requests.
|
||||
foreach ([1, 0] as $i) {
|
||||
$script .= <<<EOT
|
||||
jQuery.ajax({
|
||||
url: "$this->baseUrl/csrf_race/get_csrf_token/$i",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
success: function(response) {
|
||||
jQuery('body').append("<p class='csrf$i'></p>");
|
||||
jQuery('.csrf$i').html(response);
|
||||
},
|
||||
error: function() {
|
||||
jQuery('body').append('Nothing');
|
||||
}
|
||||
});
|
||||
EOT;
|
||||
}
|
||||
$this->getSession()->getDriver()->executeScript($script);
|
||||
$token0 = $this->assertSession()->waitForElement('css', '.csrf0')->getHtml();
|
||||
$token1 = $this->assertSession()->waitForElement('css', '.csrf1')->getHtml();
|
||||
$this->assertNotNull($token0);
|
||||
$this->assertNotNull($token1);
|
||||
$this->assertEquals($token0, $token1);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core\Field;
|
||||
|
||||
use Drupal\Component\Serialization\Json;
|
||||
use Drupal\Core\Entity\Entity\EntityViewDisplay;
|
||||
use Drupal\entity_test\Entity\EntityTest;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the 'timestamp' formatter when is used with time difference setting.
|
||||
*
|
||||
* @group Field
|
||||
*/
|
||||
class TimestampFormatterWithTimeDiffTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test', 'field'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Testing entity.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\ContentEntityInterface
|
||||
*/
|
||||
protected $entity;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
if ($this->name() === 'testNoRefreshInterval') {
|
||||
$this->markTestSkipped("Skipped due to frequent random test failures. See https://www.drupal.org/project/drupal/issues/3400150");
|
||||
}
|
||||
|
||||
parent::setUp();
|
||||
|
||||
FieldStorageConfig::create([
|
||||
'entity_type' => 'entity_test',
|
||||
'field_name' => 'time_field',
|
||||
'type' => 'timestamp',
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'entity_type' => 'entity_test',
|
||||
'bundle' => 'entity_test',
|
||||
'field_name' => 'time_field',
|
||||
'label' => $this->randomString(),
|
||||
])->save();
|
||||
$display = EntityViewDisplay::create([
|
||||
'targetEntityType' => 'entity_test',
|
||||
'bundle' => 'entity_test',
|
||||
'mode' => 'default',
|
||||
]);
|
||||
$display->setComponent('time_field', [
|
||||
'type' => 'timestamp',
|
||||
'settings' => [
|
||||
'time_diff' => [
|
||||
'enabled' => TRUE,
|
||||
'future_format' => '@interval hence',
|
||||
'past_format' => '@interval ago',
|
||||
'granularity' => 2,
|
||||
'refresh' => 1,
|
||||
],
|
||||
],
|
||||
])->setStatus(TRUE)->save();
|
||||
|
||||
$account = $this->createUser([
|
||||
'view test entity',
|
||||
'administer entity_test content',
|
||||
]);
|
||||
$this->drupalLogin($account);
|
||||
|
||||
$this->entity = EntityTest::create([
|
||||
'type' => 'entity_test',
|
||||
'name' => $this->randomString(),
|
||||
'time_field' => $this->container->get('datetime.time')->getRequestTime(),
|
||||
]);
|
||||
$this->entity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the 'timestamp' formatter when is used with time difference setting.
|
||||
*/
|
||||
public function testTimestampFormatterWithTimeDiff(): void {
|
||||
$this->drupalGet($this->entity->toUrl());
|
||||
|
||||
// Unit testing Drupal.timeDiff.format(). Not using @dataProvider mechanism
|
||||
// here in order to avoid installing the site for each case.
|
||||
foreach ($this->getFormatDiffTestCases() as $case) {
|
||||
$from = \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $case['from'])->getTimestamp();
|
||||
$to = \DateTime::createFromFormat(\DateTimeInterface::RFC3339, $case['to'])->getTimestamp();
|
||||
$diff = $to - $from;
|
||||
$options = json_encode($case['options']);
|
||||
$expected_value = json_encode($case['expected_value']);
|
||||
$expected_formatted_value = $case['expected_formatted_value'];
|
||||
|
||||
// Test the returned value.
|
||||
$this->assertJsCondition("JSON.stringify(Drupal.timeDiff.format($diff, $options).value) === '$expected_value'");
|
||||
// Test the returned formatted value.
|
||||
$this->assertJsCondition("Drupal.timeDiff.format($diff, $options).formatted === '$expected_formatted_value'");
|
||||
}
|
||||
|
||||
// Unit testing Drupal.timeDiff.refreshInterval(). Not using @dataProvider
|
||||
// mechanism here in order to avoid reinstalling the site for each case.
|
||||
foreach ($this->getRefreshIntervalTestCases() as $case) {
|
||||
$interval = json_encode($case['time_diff']);
|
||||
$this->assertJsCondition("Drupal.timeDiff.refreshInterval($interval, {$case['configured_refresh_interval']}, {$case['granularity']}) === {$case['computed_refresh_interval']}");
|
||||
}
|
||||
|
||||
// Test the UI.
|
||||
$time_element = $this->getSession()->getPage()->find('css', 'time');
|
||||
|
||||
$time_diff = $time_element->getText();
|
||||
[$seconds_value] = explode(' ', $time_diff, 2);
|
||||
|
||||
// Wait up to 2 seconds to make sure that the last time difference value
|
||||
// has been refreshed.
|
||||
$this->assertJsCondition("document.getElementsByTagName('time')[0].textContent != '$time_diff'", 2000);
|
||||
$time_diff = $time_element->getText();
|
||||
[$new_seconds_value] = explode(' ', $time_diff, 2);
|
||||
$this->assertGreaterThan($seconds_value, $new_seconds_value);
|
||||
|
||||
// Once again.
|
||||
$this->assertJsCondition("document.getElementsByTagName('time')[0].textContent != '$time_diff'", 2000);
|
||||
$time_diff = $time_element->getText();
|
||||
$seconds_value = $new_seconds_value;
|
||||
[$new_seconds_value] = explode(' ', $time_diff, 2);
|
||||
$this->assertGreaterThan($seconds_value, $new_seconds_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the 'timestamp' formatter without refresh interval.
|
||||
*/
|
||||
public function testNoRefreshInterval(): void {
|
||||
// Set the refresh interval to zero, meaning "no refresh".
|
||||
$display = EntityViewDisplay::load('entity_test.entity_test.default');
|
||||
$component = $display->getComponent('time_field');
|
||||
$component['settings']['time_diff']['refresh'] = 0;
|
||||
$display->setComponent('time_field', $component)->save();
|
||||
$this->drupalGet($this->entity->toUrl());
|
||||
|
||||
$time_element = $this->getSession()->getPage()->find('css', 'time');
|
||||
$time_diff_text = $time_element->getText();
|
||||
$time_diff_settings = Json::decode($time_element->getAttribute('data-drupal-time-diff'));
|
||||
|
||||
// Check that the timestamp is represented as a time difference.
|
||||
$this->assertMatchesRegularExpression('/^\d+ seconds? ago$/', $time_diff_text);
|
||||
// Check that the refresh is zero (no refresh).
|
||||
$this->assertSame(0, $time_diff_settings['refresh']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test cases for unit testing Drupal.timeDiff.format().
|
||||
*
|
||||
* @return array[]
|
||||
* A list of test cases, each representing parameters to be passed to the
|
||||
* JavaScript function.
|
||||
*/
|
||||
protected function getFormatDiffTestCases(): array {
|
||||
return [
|
||||
'normal, granularity: 2' => [
|
||||
'from' => '2010-02-11T10:00:00+00:00',
|
||||
'to' => '2010-02-16T14:00:00+00:00',
|
||||
'options' => [
|
||||
'granularity' => 2,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'day' => 5,
|
||||
'hour' => 4,
|
||||
],
|
||||
'expected_formatted_value' => '5 days 4 hours',
|
||||
],
|
||||
'inverted, strict' => [
|
||||
'from' => '2010-02-16T14:00:00+00:00',
|
||||
'to' => '2010-02-11T10:00:00+00:00',
|
||||
'options' => [
|
||||
'granularity' => 2,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'second' => 0,
|
||||
],
|
||||
'expected_formatted_value' => '0 seconds',
|
||||
],
|
||||
'inverted, strict (strict passed explicitly)' => [
|
||||
'from' => '2010-02-16T14:00:00+00:00',
|
||||
'to' => '2010-02-11T10:00:00+00:00',
|
||||
'options' => [
|
||||
'granularity' => 2,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'second' => 0,
|
||||
],
|
||||
'expected_formatted_value' => '0 seconds',
|
||||
],
|
||||
'inverted, non-strict' => [
|
||||
'from' => '2010-02-16T14:00:00+00:00',
|
||||
'to' => '2010-02-11T10:00:00+00:00',
|
||||
'options' => [
|
||||
'granularity' => 2,
|
||||
],
|
||||
'expected_value' => [
|
||||
'day' => 5,
|
||||
'hour' => 4,
|
||||
],
|
||||
'expected_formatted_value' => '5 days 4 hours',
|
||||
],
|
||||
'normal, max granularity' => [
|
||||
'from' => '2010-02-02T10:30:45+00:00',
|
||||
'to' => '2011-06-24T11:37:02+00:00',
|
||||
'options' => [
|
||||
'granularity' => 7,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'year' => 1,
|
||||
'month' => 4,
|
||||
'week' => 3,
|
||||
'day' => 1,
|
||||
'hour' => 1,
|
||||
'minute' => 6,
|
||||
'second' => 17,
|
||||
],
|
||||
'expected_formatted_value' => '1 year 4 months 3 weeks 1 day 1 hour 6 minutes 17 seconds',
|
||||
],
|
||||
"'1 hour 0 minutes 1 second' is '1 hour'" => [
|
||||
'from' => '2010-02-02T10:30:45+00:00',
|
||||
'to' => '2010-02-02T11:30:46+00:00',
|
||||
'options' => [
|
||||
'granularity' => 3,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'hour' => 1,
|
||||
],
|
||||
'expected_formatted_value' => '1 hour',
|
||||
],
|
||||
"'1 hour 0 minutes' is '1 hour'" => [
|
||||
'from' => '2010-02-02T10:30:45+00:00',
|
||||
'to' => '2010-02-02T11:30:45+00:00',
|
||||
'options' => [
|
||||
'granularity' => 2,
|
||||
'strict' => TRUE,
|
||||
],
|
||||
'expected_value' => [
|
||||
'hour' => 1,
|
||||
],
|
||||
'expected_formatted_value' => '1 hour',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test cases for unit testing Drupal.timeDiff.refreshInterval().
|
||||
*
|
||||
* @return array[]
|
||||
* A list of test cases, each representing parameters to be passed to the
|
||||
* javascript function.
|
||||
*/
|
||||
protected function getRefreshIntervalTestCases(): array {
|
||||
return [
|
||||
'passed timeout is not altered' => [
|
||||
'time_diff' => [
|
||||
'hour' => 11,
|
||||
'minute' => 10,
|
||||
'second' => 30,
|
||||
],
|
||||
'configured_refresh_interval' => 10,
|
||||
'granularity' => 3,
|
||||
'computed_refresh_interval' => 10,
|
||||
],
|
||||
'timeout lower than the lowest interval part' => [
|
||||
'time_diff' => [
|
||||
'hour' => 11,
|
||||
'minute' => 10,
|
||||
],
|
||||
'configured_refresh_interval' => 59,
|
||||
'granularity' => 2,
|
||||
'computed_refresh_interval' => 60,
|
||||
],
|
||||
'timeout with number of parts lower than the granularity' => [
|
||||
'time_diff' => [
|
||||
'hour' => 1,
|
||||
'minute' => 0,
|
||||
],
|
||||
'configured_refresh_interval' => 10,
|
||||
'granularity' => 2,
|
||||
'computed_refresh_interval' => 60,
|
||||
],
|
||||
'big refresh interval' => [
|
||||
'time_diff' => [
|
||||
'minute' => 3,
|
||||
'second' => 30,
|
||||
],
|
||||
'configured_refresh_interval' => 1000,
|
||||
'granularity' => 1,
|
||||
'computed_refresh_interval' => 1000,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core\Field;
|
||||
|
||||
use Drupal\entity_test\Entity\EntityTest;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\views\Tests\ViewTestData;
|
||||
|
||||
/**
|
||||
* Tests the timestamp formatter used with time difference setting in views.
|
||||
*
|
||||
* @group Field
|
||||
*/
|
||||
class TimestampFormatterWithTimeDiffViewsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test', 'views_test_formatter'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Views used in test.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public static $testViews = ['formatter_timestamp_as_time_diff'];
|
||||
|
||||
/**
|
||||
* Tests the timestamp formatter used with time difference setting in views.
|
||||
*/
|
||||
public function testTimestampFormatterWithTimeDiff(): void {
|
||||
ViewTestData::createTestViews(self::class, ['views_test_formatter']);
|
||||
|
||||
$data = $this->getRowData();
|
||||
|
||||
// PHPStan requires non-empty data. Without this check complains, later,
|
||||
// that $delta and $time_diff might not be defined.
|
||||
\assert(!empty($data));
|
||||
|
||||
// Create the entities.
|
||||
foreach ($data as $delta => $row) {
|
||||
EntityTest::create([
|
||||
'type' => 'test',
|
||||
// Using this also as field class.
|
||||
'name' => "entity-$delta",
|
||||
'created' => $row['timestamp'],
|
||||
])->save();
|
||||
}
|
||||
$this->drupalGet('formatter_timestamp_as_time_diff');
|
||||
|
||||
$page = $this->getSession()->getPage();
|
||||
foreach ($data as $delta => $row) {
|
||||
$time_diff = $page->find('css', ".entity-$delta")->getText();
|
||||
$regex_pattern = "#{$row['pattern']}#";
|
||||
// Test that the correct time difference is displayed. Note that we are
|
||||
// able to check an exact match for rows that have a creation date more
|
||||
// distant, but we use regexp to check the entities that are only few
|
||||
// seconds away because of the latency introduced by the test run.
|
||||
$this->assertMatchesRegularExpression($regex_pattern, $time_diff);
|
||||
}
|
||||
|
||||
// Wait up to 2 seconds to make sure the 'right now' time difference was
|
||||
// refreshed.
|
||||
$this->assertJsCondition("document.querySelector('.entity-$delta time').textContent >= '$time_diff'", 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides data for view rows.
|
||||
*
|
||||
* @return array[]
|
||||
* A list of row data.
|
||||
*/
|
||||
protected function getRowData(): array {
|
||||
$now = \Drupal::time()->getRequestTime();
|
||||
return [
|
||||
// One year ago.
|
||||
[
|
||||
'pattern' => '1 year ago',
|
||||
'timestamp' => $now - (60 * 60 * 24 * 365),
|
||||
],
|
||||
// One month ago.
|
||||
[
|
||||
'pattern' => '1 month ago',
|
||||
'timestamp' => $now - (60 * 60 * 24 * 30),
|
||||
],
|
||||
// One week ago.
|
||||
[
|
||||
'pattern' => '1 week ago',
|
||||
'timestamp' => $now - (60 * 60 * 24 * 7),
|
||||
],
|
||||
// One day ago.
|
||||
[
|
||||
'pattern' => '1 day ago',
|
||||
'timestamp' => $now - (60 * 60 * 24),
|
||||
],
|
||||
// One hour ago.
|
||||
[
|
||||
'pattern' => '1 hour ago',
|
||||
'timestamp' => $now - (60 * 60),
|
||||
],
|
||||
// One minute ago.
|
||||
[
|
||||
'pattern' => '\d+ minute[s]?(?: \d+ second[s]?)? ago',
|
||||
'timestamp' => $now - 60,
|
||||
],
|
||||
// One minute hence.
|
||||
[
|
||||
'pattern' => '\d+ second[s]?[ hence]?',
|
||||
'timestamp' => $now + 60,
|
||||
],
|
||||
// One hour hence.
|
||||
[
|
||||
'pattern' => '59 minutes \d+ second[s]? hence',
|
||||
'timestamp' => $now + (60 * 60),
|
||||
],
|
||||
// One day hence.
|
||||
[
|
||||
'pattern' => '23 hours 59 minutes hence',
|
||||
'timestamp' => $now + (60 * 60 * 24),
|
||||
],
|
||||
// One week hence.
|
||||
[
|
||||
'pattern' => '6 days 23 hours hence',
|
||||
'timestamp' => $now + (60 * 60 * 24 * 7),
|
||||
],
|
||||
// A little more than 1 year hence (one year + 1 hour).
|
||||
[
|
||||
'pattern' => '1 year hence',
|
||||
'timestamp' => $now + (60 * 60 * 24 * 365) + (60 * 60),
|
||||
],
|
||||
// Right now.
|
||||
[
|
||||
'pattern' => '\d+ second[s]?[ ago]?',
|
||||
'timestamp' => $now,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core\Form;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests for form grouping elements.
|
||||
*
|
||||
* @group form
|
||||
*/
|
||||
class FormGroupingElementsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* Required modules.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $modules = ['form_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$account = $this->drupalCreateUser();
|
||||
$this->drupalLogin($account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that vertical tab children become visible.
|
||||
*
|
||||
* Makes sure that a child element of a vertical tab that is not visible,
|
||||
* becomes visible when the tab is clicked, a fragment link to the child is
|
||||
* clicked or when the URI fragment pointing to that child changes.
|
||||
*/
|
||||
public function testVerticalTabChildVisibility(): void {
|
||||
$session = $this->getSession();
|
||||
$web_assert = $this->assertSession();
|
||||
|
||||
// Request the group vertical tabs testing page with a fragment identifier
|
||||
// to the second element.
|
||||
$this->drupalGet('form-test/group-vertical-tabs', ['fragment' => 'edit-element-2']);
|
||||
|
||||
$page = $session->getPage();
|
||||
|
||||
$tab_link_1 = $page->find('css', '.vertical-tabs__menu-item > a');
|
||||
|
||||
$child_1_selector = '#edit-element';
|
||||
$child_1 = $page->find('css', $child_1_selector);
|
||||
|
||||
$child_2_selector = '#edit-element-2';
|
||||
$child_2 = $page->find('css', $child_2_selector);
|
||||
|
||||
// Assert that the child in the second vertical tab becomes visible.
|
||||
// It should be visible after initial load due to the fragment in the URI.
|
||||
$this->assertTrue($child_2->isVisible(), 'Child 2 is visible due to a URI fragment');
|
||||
|
||||
// Click on a fragment link pointing to an invisible child inside an
|
||||
// inactive vertical tab.
|
||||
$session->executeScript("jQuery('<a href=\"$child_1_selector\"></a>').insertAfter('h1')[0].click()");
|
||||
|
||||
// Assert that the child in the first vertical tab becomes visible.
|
||||
$web_assert->waitForElementVisible('css', $child_1_selector, 50);
|
||||
|
||||
// Trigger a URI fragment change (hashchange) to show the second vertical
|
||||
// tab again.
|
||||
$session->executeScript("location.replace('$child_2_selector')");
|
||||
|
||||
// Assert that the child in the second vertical tab becomes visible again.
|
||||
$web_assert->waitForElementVisible('css', $child_2_selector, 50);
|
||||
|
||||
$tab_link_1->click();
|
||||
|
||||
// Assert that the child in the first vertical tab is visible again after
|
||||
// a click on the first tab.
|
||||
$this->assertTrue($child_1->isVisible(), 'Child 1 is visible after clicking the parent tab');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that details element children become visible.
|
||||
*
|
||||
* Makes sure that a child element of a details element that is not visible,
|
||||
* becomes visible when a fragment link to the child is clicked or when the
|
||||
* URI fragment pointing to that child changes.
|
||||
*/
|
||||
public function testDetailsChildVisibility(): void {
|
||||
$session = $this->getSession();
|
||||
$web_assert = $this->assertSession();
|
||||
|
||||
// Store reusable JavaScript code to remove the current URI fragment and
|
||||
// close all details.
|
||||
$reset_js = "location.replace('#'); jQuery('details').removeAttr('open')";
|
||||
|
||||
// Request the group details testing page.
|
||||
$this->drupalGet('form-test/group-details');
|
||||
|
||||
$page = $session->getPage();
|
||||
|
||||
$session->executeScript($reset_js);
|
||||
|
||||
$child_selector = '#edit-element';
|
||||
$child = $page->find('css', $child_selector);
|
||||
|
||||
// Assert that the child is not visible.
|
||||
$this->assertFalse($child->isVisible(), 'Child is not visible');
|
||||
|
||||
// Trigger a URI fragment change (hashchange) to open all parent details
|
||||
// elements of the child.
|
||||
$session->executeScript("location.replace('$child_selector')");
|
||||
|
||||
// Assert that the child becomes visible again after a hash change.
|
||||
$web_assert->waitForElementVisible('css', $child_selector, 50);
|
||||
|
||||
$session->executeScript($reset_js);
|
||||
|
||||
// Click on a fragment link pointing to an invisible child inside a closed
|
||||
// details element.
|
||||
$session->executeScript("jQuery('<a href=\"$child_selector\"></a>').insertAfter('h1')[0].click()");
|
||||
|
||||
// Assert that the child is visible again after a fragment link click.
|
||||
$web_assert->waitForElementVisible('css', $child_selector, 50);
|
||||
|
||||
// Find the summary belonging to the closest details element.
|
||||
$summary = $page->find('css', '#edit-meta > summary');
|
||||
|
||||
// Assert that both aria-expanded and aria-pressed are true.
|
||||
$this->assertEquals('true', $summary->getAttribute('aria-expanded'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms tabs containing a field with a validation error are open.
|
||||
*/
|
||||
public function testVerticalTabValidationVisibility(): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$this->drupalGet('form-test/group-vertical-tabs');
|
||||
$page->clickLink('Second group element');
|
||||
$input_field = $assert_session->waitForField('element_2');
|
||||
$this->assertNotNull($input_field);
|
||||
|
||||
// Enter a value that will trigger a validation error.
|
||||
$input_field->setValue('bad');
|
||||
|
||||
// Switch to a tab that does not have the error-causing field.
|
||||
$page->clickLink('First group element');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '#edit-meta'));
|
||||
|
||||
// Submit the form.
|
||||
$page->pressButton('Save');
|
||||
|
||||
// Confirm there is an error.
|
||||
$assert_session->waitForText('there was an error');
|
||||
|
||||
// Confirm the tab containing the field with error is open.
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', '[name="element_2"].error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests form submit with a required field in closed details element.
|
||||
*/
|
||||
public function testDetailsContainsRequiredTextfield(): void {
|
||||
$this->drupalGet('form_test/details-contains-required-textfield');
|
||||
$details = $this->assertSession()->elementExists('css', 'details[data-drupal-selector="edit-meta"]');
|
||||
|
||||
// Make sure details element is not open at the beginning.
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
|
||||
$textfield = $this->assertSession()->elementExists('css', 'input[name="required_textfield_in_details"]');
|
||||
|
||||
// The text field inside the details element is not visible too.
|
||||
$this->assertFalse($textfield->isVisible(), 'Text field is not visible');
|
||||
|
||||
// Submit the form with invalid data in the required fields.
|
||||
$this->assertSession()
|
||||
->elementExists('css', 'input[data-drupal-selector="edit-submit"]')
|
||||
->click();
|
||||
// Confirm the required field is visible.
|
||||
$this->assertTrue($textfield->isVisible(), 'Text field is visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests required field in closed details element with ajax form.
|
||||
*/
|
||||
public function testDetailsContainsRequiredTextfieldAjaxForm(): void {
|
||||
$this->drupalGet('form_test/details-contains-required-textfield/true');
|
||||
$assert_session = $this->assertSession();
|
||||
$textfield = $assert_session->elementExists('css', 'input[name="required_textfield_in_details"]');
|
||||
|
||||
// Submit the ajax form to open the details element at the first time.
|
||||
$assert_session->elementExists('css', 'input[value="Submit Ajax"]')
|
||||
->click();
|
||||
|
||||
$assert_session->waitForElementVisible('css', 'input[name="required_textfield_in_details"]');
|
||||
|
||||
// Close the details element.
|
||||
$assert_session->elementExists('css', 'form summary')
|
||||
->click();
|
||||
|
||||
// Submit the form with invalid data in the required fields without ajax.
|
||||
$assert_session->elementExists('css', 'input[data-drupal-selector="edit-submit"]')
|
||||
->click();
|
||||
|
||||
// Confirm the required field is visible.
|
||||
$this->assertTrue($textfield->isVisible(), 'Text field is visible');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,629 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core\Form;
|
||||
|
||||
use Drupal\filter\Entity\FilterFormat;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the state of elements based on another elements.
|
||||
*
|
||||
* The form being tested is JavascriptStatesForm provided by the 'form_test'
|
||||
* module under 'system' (core/modules/system/tests/module/form_test).
|
||||
*
|
||||
* @see Drupal\form_test\Form\JavascriptStatesForm
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JavascriptStatesTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['form_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// Add text formats.
|
||||
$filtered_html_format = FilterFormat::create([
|
||||
'format' => 'filtered_html',
|
||||
'name' => 'Filtered HTML',
|
||||
'weight' => 0,
|
||||
'filters' => [],
|
||||
]);
|
||||
$filtered_html_format->save();
|
||||
$full_html_format = FilterFormat::create([
|
||||
'format' => 'full_html',
|
||||
'name' => 'Full HTML',
|
||||
'weight' => 1,
|
||||
'filters' => [],
|
||||
]);
|
||||
$full_html_format->save();
|
||||
$normal_user = $this->drupalCreateUser([
|
||||
'use text format filtered_html',
|
||||
'use text format full_html',
|
||||
]);
|
||||
$this->drupalLogin($normal_user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the JavaScript #states functionality of form elements.
|
||||
*
|
||||
* To avoid the large cost of a dataProvider in FunctionalJavascript tests,
|
||||
* this is a single public test method that invokes a series of protected
|
||||
* methods to do assertions on specific kinds of triggering elements.
|
||||
*/
|
||||
public function testJavascriptStates(): void {
|
||||
$this->doCheckboxTriggerTests();
|
||||
$this->doCheckboxesTriggerTests();
|
||||
$this->doTextfieldTriggerTests();
|
||||
$this->doRadiosTriggerTests();
|
||||
$this->doSelectTriggerTests();
|
||||
$this->doMultipleSelectTriggerTests();
|
||||
$this->doMultipleTriggerTests();
|
||||
$this->doNestedTriggerTests();
|
||||
$this->doElementsDisabledStateTests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a checkbox element.
|
||||
*/
|
||||
protected function doCheckboxTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$trigger = $page->findField('checkbox_trigger');
|
||||
$this->assertNotEmpty($trigger);
|
||||
$textfield_invisible_element = $page->findField('textfield_invisible_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($textfield_invisible_element);
|
||||
$textfield_required_element = $page->findField('textfield_required_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($textfield_required_element);
|
||||
$textfield_readonly_element = $page->findField('textfield_readonly_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($textfield_readonly_element);
|
||||
$textarea_readonly_element = $page->findField('textarea_readonly_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($textarea_readonly_element);
|
||||
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-checkbox-trigger-checked');
|
||||
$textfield_in_details = $details->findField('textfield_in_details');
|
||||
$this->assertNotEmpty($textfield_in_details);
|
||||
$checkbox_checked_element = $page->findField('checkbox_checked_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($checkbox_checked_element);
|
||||
$checkbox_unchecked_element = $page->findField('checkbox_unchecked_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($checkbox_unchecked_element);
|
||||
$checkbox_visible_element = $page->findField('checkbox_visible_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($checkbox_visible_element);
|
||||
$text_format_invisible_value = $page->findField('text_format_invisible_when_checkbox_trigger_checked[value]');
|
||||
$this->assertNotEmpty($text_format_invisible_value);
|
||||
$text_format_invisible_format = $page->findField('text_format_invisible_when_checkbox_trigger_checked[format]');
|
||||
$this->assertNotEmpty($text_format_invisible_format);
|
||||
$link = $page->findLink('Link states test');
|
||||
|
||||
$checkboxes_all_checked_element_value1 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value1]');
|
||||
$this->assertNotEmpty($checkboxes_all_checked_element_value1);
|
||||
$checkboxes_all_checked_element_value2 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value2]');
|
||||
$this->assertNotEmpty($checkboxes_all_checked_element_value2);
|
||||
$checkboxes_all_checked_element_value3 = $page->findField('checkboxes_all_checked_when_checkbox_trigger_checked[value3]');
|
||||
$this->assertNotEmpty($checkboxes_all_checked_element_value3);
|
||||
|
||||
$checkboxes_some_checked_element_value1 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value1]');
|
||||
$this->assertNotEmpty($checkboxes_some_checked_element_value1);
|
||||
$checkboxes_some_checked_element_value2 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value2]');
|
||||
$this->assertNotEmpty($checkboxes_some_checked_element_value2);
|
||||
$checkboxes_some_checked_element_value3 = $page->findField('checkboxes_some_checked_when_checkbox_trigger_checked[value3]');
|
||||
$this->assertNotEmpty($checkboxes_some_checked_element_value3);
|
||||
|
||||
$checkboxes_all_disabled_element_value1 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value1]');
|
||||
$this->assertNotEmpty($checkboxes_all_disabled_element_value1);
|
||||
$checkboxes_all_disabled_element_value2 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value2]');
|
||||
$this->assertNotEmpty($checkboxes_all_disabled_element_value2);
|
||||
$checkboxes_all_disabled_element_value3 = $page->findField('checkboxes_all_disabled_when_checkbox_trigger_checked[value3]');
|
||||
$this->assertNotEmpty($checkboxes_all_disabled_element_value3);
|
||||
|
||||
$checkboxes_some_disabled_element_value1 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value1]');
|
||||
$this->assertNotEmpty($checkboxes_some_disabled_element_value1);
|
||||
$checkboxes_some_disabled_element_value2 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value2]');
|
||||
$this->assertNotEmpty($checkboxes_some_disabled_element_value2);
|
||||
$checkboxes_some_disabled_element_value3 = $page->findField('checkboxes_some_disabled_when_checkbox_trigger_checked[value3]');
|
||||
|
||||
$radios_checked_element = $page->findField('radios_checked_when_checkbox_trigger_checked');
|
||||
$this->assertNotEmpty($radios_checked_element);
|
||||
|
||||
// We want to select the specific radio buttons, not the whole radios field
|
||||
// itself.
|
||||
$radios_all_disabled_value1 = $this->xpath(
|
||||
'//input[@name=:name][@value=:value]',
|
||||
[':name' => 'radios_all_disabled_when_checkbox_trigger_checked', ':value' => 'value1']);
|
||||
$this->assertCount(1, $radios_all_disabled_value1);
|
||||
// We want to access the radio button directly for the rest of the test, so
|
||||
// take it out of the array we got back from xpath().
|
||||
$radios_all_disabled_value1 = reset($radios_all_disabled_value1);
|
||||
$radios_all_disabled_value2 = $this->xpath(
|
||||
'//input[@name=:name][@value=:value]',
|
||||
[':name' => 'radios_all_disabled_when_checkbox_trigger_checked', ':value' => 'value2']);
|
||||
$this->assertCount(1, $radios_all_disabled_value2);
|
||||
$radios_all_disabled_value2 = reset($radios_all_disabled_value2);
|
||||
|
||||
$radios_some_disabled_value1 = $this->xpath(
|
||||
'//input[@name=:name][@value=:value]',
|
||||
[':name' => 'radios_some_disabled_when_checkbox_trigger_checked', ':value' => 'value1']);
|
||||
$this->assertCount(1, $radios_some_disabled_value1);
|
||||
$radios_some_disabled_value1 = reset($radios_some_disabled_value1);
|
||||
$radios_some_disabled_value2 = $this->xpath(
|
||||
'//input[@name=:name][@value=:value]',
|
||||
[':name' => 'radios_some_disabled_when_checkbox_trigger_checked', ':value' => 'value2']);
|
||||
$this->assertCount(1, $radios_some_disabled_value2);
|
||||
$radios_some_disabled_value2 = reset($radios_some_disabled_value2);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertTrue($textfield_invisible_element->isVisible());
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
$this->assertFalse($textfield_in_details->isVisible());
|
||||
$this->assertFalse($textfield_required_element->hasAttribute('required'));
|
||||
$this->assertFalse($textfield_required_element->hasAttribute('aria-required'));
|
||||
$this->assertFalse($textfield_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertFalse($textarea_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertFalse($checkbox_checked_element->isChecked());
|
||||
$this->assertTrue($checkbox_unchecked_element->isChecked());
|
||||
$this->assertFalse($checkbox_visible_element->isVisible());
|
||||
$this->assertTrue($text_format_invisible_value->isVisible());
|
||||
$this->assertTrue($text_format_invisible_format->isVisible());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value1->isChecked());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value2->isChecked());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value3->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value1->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value3->isChecked());
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_checked_element->isChecked());
|
||||
$this->assertEquals(NULL, $radios_checked_element->getValue());
|
||||
$this->assertFalse($radios_all_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_all_disabled_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_some_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
|
||||
// Check if the link is visible.
|
||||
$this->assertTrue($link->isVisible());
|
||||
// Check enter password is visible.
|
||||
$this->assertSession()->pageTextContains('Enter password');
|
||||
|
||||
// Change state: check the checkbox.
|
||||
$trigger->check();
|
||||
// Verify triggered state.
|
||||
$this->assertFalse($textfield_invisible_element->isVisible());
|
||||
$this->assertEquals('required', $textfield_required_element->getAttribute('required'));
|
||||
$this->assertFalse($textfield_required_element->hasAttribute('aria-required'));
|
||||
$this->assertTrue($textfield_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertTrue($textarea_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertTrue($details->hasAttribute('open'));
|
||||
$this->assertTrue($textfield_in_details->isVisible());
|
||||
$this->assertTrue($checkbox_checked_element->isChecked());
|
||||
$this->assertFalse($checkbox_unchecked_element->isChecked());
|
||||
$this->assertTrue($checkbox_visible_element->isVisible());
|
||||
$this->assertFalse($text_format_invisible_value->isVisible());
|
||||
$this->assertFalse($text_format_invisible_format->isVisible());
|
||||
// All 3 of the other set should be checked.
|
||||
$this->assertTrue($checkboxes_all_checked_element_value1->isChecked());
|
||||
$this->assertTrue($checkboxes_all_checked_element_value2->isChecked());
|
||||
$this->assertTrue($checkboxes_all_checked_element_value3->isChecked());
|
||||
// Value 1 and 3 should now be checked.
|
||||
$this->assertTrue($checkboxes_some_checked_element_value1->isChecked());
|
||||
$this->assertTrue($checkboxes_some_checked_element_value3->isChecked());
|
||||
// Only value 2 should remain unchecked.
|
||||
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
|
||||
// All 3 of these should be disabled.
|
||||
$this->assertTrue($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertTrue($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertTrue($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
|
||||
// Only values 1 and 3 should be disabled, 2 should still be enabled.
|
||||
$this->assertTrue($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertTrue($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
|
||||
$this->assertEquals('value1', $radios_checked_element->getValue());
|
||||
// Both of these should now be disabled.
|
||||
$this->assertTrue($radios_all_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertTrue($radios_all_disabled_value2->hasAttribute('disabled'));
|
||||
// Only value1 should be disabled, value 2 should remain enabled.
|
||||
$this->assertTrue($radios_some_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
|
||||
// The link shouldn't be visible.
|
||||
$this->assertFalse($link->isVisible());
|
||||
// Check enter password is not visible.
|
||||
$this->assertSession()->pageTextNotContains('Enter password');
|
||||
|
||||
// Change state: uncheck the checkbox.
|
||||
$trigger->uncheck();
|
||||
// Verify triggered state, which should match the initial state.
|
||||
$this->assertTrue($textfield_invisible_element->isVisible());
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
$this->assertFalse($textfield_in_details->isVisible());
|
||||
$this->assertFalse($textfield_required_element->hasAttribute('required'));
|
||||
$this->assertFalse($textfield_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertFalse($textarea_readonly_element->hasAttribute('readonly'));
|
||||
$this->assertFalse($checkbox_checked_element->isChecked());
|
||||
$this->assertTrue($checkbox_unchecked_element->isChecked());
|
||||
$this->assertFalse($checkbox_visible_element->isVisible());
|
||||
$this->assertTrue($text_format_invisible_value->isVisible());
|
||||
$this->assertTrue($text_format_invisible_format->isVisible());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value1->isChecked());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value2->isChecked());
|
||||
$this->assertFalse($checkboxes_all_checked_element_value3->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value1->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value2->isChecked());
|
||||
$this->assertFalse($checkboxes_some_checked_element_value3->isChecked());
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_all_disabled_element_value3->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($checkboxes_some_disabled_element_value3->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_checked_element->isChecked());
|
||||
$this->assertEquals(NULL, $radios_checked_element->getValue());
|
||||
$this->assertFalse($radios_all_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_all_disabled_value2->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_some_disabled_value1->hasAttribute('disabled'));
|
||||
$this->assertFalse($radios_some_disabled_value2->hasAttribute('disabled'));
|
||||
// Check if the link is turned back to visible state.
|
||||
$this->assertTrue($link->isVisible());
|
||||
// Check enter password is visible.
|
||||
$this->assertSession()->pageTextContains('Enter password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a checkboxes element.
|
||||
*/
|
||||
protected function doCheckboxesTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$trigger_value1 = $page->findField('checkboxes_trigger[value1]');
|
||||
$this->assertNotEmpty($trigger_value1);
|
||||
$trigger_value2 = $page->findField('checkboxes_trigger[value2]');
|
||||
$this->assertNotEmpty($trigger_value2);
|
||||
$trigger_value3 = $page->findField('checkboxes_trigger[value3]');
|
||||
$this->assertNotEmpty($trigger_value3);
|
||||
$textfield_visible_value2 = $page->findField('textfield_visible_when_checkboxes_trigger_value2_checked');
|
||||
$this->assertNotEmpty($textfield_visible_value2);
|
||||
$textfield_visible_value3 = $page->findField('textfield_visible_when_checkboxes_trigger_value3_checked');
|
||||
$this->assertNotEmpty($textfield_visible_value3);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertFalse($textfield_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
// Change state: check the 'Value 1' checkbox.
|
||||
$trigger_value1->check();
|
||||
$this->assertFalse($textfield_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
// Change state: check the 'Value 2' checkbox.
|
||||
$trigger_value2->check();
|
||||
$this->assertTrue($textfield_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
// Change state: check the 'Value 3' checkbox.
|
||||
$trigger_value3->check();
|
||||
$this->assertTrue($textfield_visible_value2->isVisible());
|
||||
$this->assertTrue($textfield_visible_value3->isVisible());
|
||||
// Change state: uncheck the 'Value 2' checkbox.
|
||||
$trigger_value2->uncheck();
|
||||
$this->assertFalse($textfield_visible_value2->isVisible());
|
||||
$this->assertTrue($textfield_visible_value3->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a textfield element.
|
||||
*/
|
||||
protected function doTextfieldTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$trigger = $page->findField('textfield_trigger');
|
||||
$this->assertNotEmpty($trigger);
|
||||
$checkbox_checked_target = $page->findField('checkbox_checked_when_textfield_trigger_filled');
|
||||
$this->assertNotEmpty($checkbox_checked_target);
|
||||
$checkbox_unchecked_target = $page->findField('checkbox_unchecked_when_textfield_trigger_filled');
|
||||
$this->assertNotEmpty($checkbox_unchecked_target);
|
||||
$select_invisible_target = $page->findField('select_invisible_when_textfield_trigger_filled');
|
||||
$this->assertNotEmpty($select_invisible_target);
|
||||
$select_visible_target = $page->findField('select_visible_when_textfield_trigger_filled');
|
||||
$this->assertNotEmpty($select_visible_target);
|
||||
$textfield_required_target = $page->findField('textfield_required_when_textfield_trigger_filled');
|
||||
$this->assertNotEmpty($textfield_required_target);
|
||||
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-textfield-trigger-filled');
|
||||
$textfield_in_details = $details->findField('textfield_in_details');
|
||||
$this->assertNotEmpty($textfield_in_details);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertFalse($checkbox_checked_target->isChecked());
|
||||
$this->assertTrue($checkbox_unchecked_target->isChecked());
|
||||
$this->assertTrue($select_invisible_target->isVisible());
|
||||
$this->assertFalse($select_visible_target->isVisible());
|
||||
$this->assertFalse($textfield_required_target->hasAttribute('required'));
|
||||
$this->assertFalse($textfield_required_target->hasAttribute('aria-required'));
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
$this->assertFalse($textfield_in_details->isVisible());
|
||||
|
||||
// Change state: fill the textfield.
|
||||
$trigger->setValue('filled');
|
||||
// Verify triggered state.
|
||||
$this->assertTrue($checkbox_checked_target->isChecked());
|
||||
$this->assertFalse($checkbox_unchecked_target->isChecked());
|
||||
$this->assertFalse($select_invisible_target->isVisible());
|
||||
$this->assertTrue($select_visible_target->isVisible());
|
||||
$this->assertEquals('required', $textfield_required_target->getAttribute('required'));
|
||||
$this->assertFalse($textfield_required_target->hasAttribute('aria-required'));
|
||||
$this->assertTrue($details->hasAttribute('open'));
|
||||
$this->assertTrue($textfield_in_details->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a radios element.
|
||||
*/
|
||||
protected function doRadiosTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$trigger = $page->findField('radios_trigger');
|
||||
$this->assertNotEmpty($trigger);
|
||||
$fieldset_visible_when_value2 = $this->assertSession()->elementExists('css', '#edit-fieldset-visible-when-radios-trigger-has-value2');
|
||||
$textfield_in_fieldset = $fieldset_visible_when_value2->findField('textfield_in_fieldset');
|
||||
$this->assertNotEmpty($textfield_in_fieldset);
|
||||
$checkbox_checked_target = $page->findField('checkbox_checked_when_radios_trigger_has_value3');
|
||||
$this->assertNotEmpty($checkbox_checked_target);
|
||||
$checkbox_unchecked_target = $page->findField('checkbox_unchecked_when_radios_trigger_has_value3');
|
||||
$this->assertNotEmpty($checkbox_unchecked_target);
|
||||
$textfield_invisible_target = $page->findField('textfield_invisible_when_radios_trigger_has_value2');
|
||||
$this->assertNotEmpty($textfield_invisible_target);
|
||||
$select_required_target = $page->findField('select_required_when_radios_trigger_has_value2');
|
||||
$this->assertNotEmpty($select_required_target);
|
||||
$details = $this->assertSession()->elementExists('css', '#edit-details-expanded-when-radios-trigger-has-value3');
|
||||
$textfield_in_details = $details->findField('textfield_in_details');
|
||||
$this->assertNotEmpty($textfield_in_details);
|
||||
|
||||
// Verify initial state, both the fieldset and something inside it.
|
||||
$this->assertFalse($fieldset_visible_when_value2->isVisible());
|
||||
$this->assertFalse($textfield_in_fieldset->isVisible());
|
||||
$this->assertFalse($checkbox_checked_target->isChecked());
|
||||
$this->assertTrue($checkbox_unchecked_target->isChecked());
|
||||
$this->assertTrue($textfield_invisible_target->isVisible());
|
||||
$this->assertFalse($select_required_target->hasAttribute('required'));
|
||||
$this->assertFalse($select_required_target->hasAttribute('aria-required'));
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
$this->assertFalse($textfield_in_details->isVisible());
|
||||
|
||||
// Change state: select the value2 radios option.
|
||||
$trigger->selectOption('value2');
|
||||
// Verify triggered state.
|
||||
$this->assertTrue($fieldset_visible_when_value2->isVisible());
|
||||
$this->assertTrue($textfield_in_fieldset->isVisible());
|
||||
$this->assertFalse($textfield_invisible_target->isVisible());
|
||||
$this->assertTrue($select_required_target->hasAttribute('required'));
|
||||
$this->assertFalse($select_required_target->hasAttribute('aria-required'));
|
||||
// Checkboxes and details should not have changed state, yet.
|
||||
$this->assertFalse($checkbox_checked_target->isChecked());
|
||||
$this->assertTrue($checkbox_unchecked_target->isChecked());
|
||||
$this->assertFalse($details->hasAttribute('open'));
|
||||
$this->assertFalse($textfield_in_details->isVisible());
|
||||
// Change state: select the value3 radios option.
|
||||
$trigger->selectOption('value3');
|
||||
// Fieldset and contents should re-disappear.
|
||||
$this->assertFalse($fieldset_visible_when_value2->isVisible());
|
||||
$this->assertFalse($textfield_in_fieldset->isVisible());
|
||||
// Textfield and select should revert to initial state.
|
||||
$this->assertTrue($textfield_invisible_target->isVisible());
|
||||
$this->assertFalse($select_required_target->hasAttribute('required'));
|
||||
$this->assertFalse($select_required_target->hasAttribute('aria-required'));
|
||||
// Checkbox states should now change.
|
||||
$this->assertTrue($checkbox_checked_target->isChecked());
|
||||
$this->assertFalse($checkbox_unchecked_target->isChecked());
|
||||
// Details should now be expanded.
|
||||
$this->assertTrue($details->hasAttribute('open'));
|
||||
$this->assertTrue($textfield_in_details->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a select element.
|
||||
*/
|
||||
protected function doSelectTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$trigger = $page->findField('select_trigger');
|
||||
$this->assertNotEmpty($trigger);
|
||||
$item_visible_value2 = $this->assertSession()->elementExists('css', '#edit-item-visible-when-select-trigger-has-value2');
|
||||
$textfield_visible_value3 = $page->findField('textfield_visible_when_select_trigger_has_value3');
|
||||
$this->assertNotEmpty($textfield_visible_value3);
|
||||
$textfield_visible_value2_or_value3 = $page->findField('textfield_visible_when_select_trigger_has_value2_or_value3');
|
||||
$this->assertNotEmpty($textfield_visible_value2_or_value3);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
|
||||
// Change state: select the 'Value 2' option.
|
||||
$trigger->setValue('value2');
|
||||
$this->assertTrue($item_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
|
||||
// Change state: select the 'Value 3' option.
|
||||
$trigger->setValue('value3');
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertTrue($textfield_visible_value3->isVisible());
|
||||
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
|
||||
|
||||
$this->container->get('module_installer')->install(['big_pipe']);
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$select_visible_2 = $this->assertSession()->elementExists('css', 'select[name="select_visible_2"]');
|
||||
$select_visible_3 = $this->assertSession()->elementExists('css', 'select[name="select_visible_3"]');
|
||||
$this->assertFalse($select_visible_3->isVisible());
|
||||
|
||||
$select_visible_2->setValue('1');
|
||||
$this->assertTrue($select_visible_3->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by a multiple select element.
|
||||
*/
|
||||
protected function doMultipleSelectTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
// Find trigger and target elements.
|
||||
$trigger = $page->findField('multiple_select_trigger[]');
|
||||
$this->assertNotEmpty($trigger);
|
||||
$item_visible_value2 = $this->assertSession()->elementExists('css', '#edit-item-visible-when-multiple-select-trigger-has-value2');
|
||||
$item_visible_no_value = $this->assertSession()->elementExists('css', '#edit-item-visible-when-multiple-select-trigger-has-no-value');
|
||||
$textfield_visible_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value3');
|
||||
$this->assertNotEmpty($textfield_visible_value3);
|
||||
$textfield_visible_value2_or_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value2_or_value3');
|
||||
$this->assertNotEmpty($textfield_visible_value2_or_value3);
|
||||
$textfield_visible_value2_and_value3 = $page->findField('textfield_visible_when_multiple_select_trigger_has_value2_and_value3');
|
||||
$this->assertNotEmpty($textfield_visible_value2_and_value3);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertTrue($item_visible_no_value->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
|
||||
// Change state: select the 'Value 2' option.
|
||||
$trigger->setValue('value2');
|
||||
$this->assertTrue($item_visible_value2->isVisible());
|
||||
$this->assertFalse($item_visible_no_value->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
|
||||
// Change state: select the 'Value 3' option.
|
||||
$trigger->setValue('value3');
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertFalse($item_visible_no_value->isVisible());
|
||||
$this->assertTrue($textfield_visible_value3->isVisible());
|
||||
$this->assertTrue($textfield_visible_value2_or_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_and_value3->isVisible());
|
||||
// Change state: select 'Value2' and 'Value 3' options.
|
||||
$trigger->setValue(['value2', 'value3']);
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertFalse($item_visible_no_value->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
|
||||
$this->assertTrue($textfield_visible_value2_and_value3->isVisible());
|
||||
// Restore initial trigger state (clear the values).
|
||||
$trigger->setValue([]);
|
||||
// Make sure the initial element states are restored.
|
||||
$this->assertFalse($item_visible_value2->isVisible());
|
||||
$this->assertFalse($textfield_visible_value3->isVisible());
|
||||
$this->assertFalse($textfield_visible_value2_or_value3->isVisible());
|
||||
// @todo These last two look to be correct, but the assertion is failing.
|
||||
// @see https://www.drupal.org/project/drupal/issues/3367310
|
||||
// $this->assertTrue($item_visible_no_value->isVisible());
|
||||
// $this->assertFalse($textfield_visible_value2_and_value3->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of elements triggered by multiple elements.
|
||||
*/
|
||||
protected function doMultipleTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$select_trigger = $page->findField('select_trigger');
|
||||
$this->assertNotEmpty($select_trigger);
|
||||
$textfield_trigger = $page->findField('textfield_trigger');
|
||||
$this->assertNotEmpty($textfield_trigger);
|
||||
$item_visible_value2_and_textfield = $this->assertSession()->elementExists('css', '#edit-item-visible-when-select-trigger-has-value2-and-textfield-trigger-filled');
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertFalse($item_visible_value2_and_textfield->isVisible());
|
||||
// Change state: select the 'Value 2' option.
|
||||
$select_trigger->setValue('value2');
|
||||
$this->assertFalse($item_visible_value2_and_textfield->isVisible());
|
||||
// Change state: fill the textfield.
|
||||
$textfield_trigger->setValue('filled');
|
||||
$this->assertTrue($item_visible_value2_and_textfield->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests states of radios element triggered by other radios element.
|
||||
*/
|
||||
protected function doNestedTriggerTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Find trigger and target elements.
|
||||
$radios_opposite1 = $page->findField('radios_opposite1');
|
||||
$this->assertNotEmpty($radios_opposite1);
|
||||
$radios_opposite2 = $page->findField('radios_opposite2');
|
||||
$this->assertNotEmpty($radios_opposite2);
|
||||
|
||||
// Verify initial state.
|
||||
$this->assertEquals('0', $radios_opposite1->getValue());
|
||||
$this->assertEquals('1', $radios_opposite2->getValue());
|
||||
|
||||
// Set $radios_opposite2 value to 0, $radios_opposite1 value should be 1.
|
||||
$radios_opposite2->setValue('0');
|
||||
$this->assertEquals('1', $radios_opposite1->getValue());
|
||||
|
||||
// Set $radios_opposite1 value to 1, $radios_opposite2 value should be 0.
|
||||
$radios_opposite1->setValue('0');
|
||||
$this->assertEquals('1', $radios_opposite2->getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the submit button, select and textarea disabled states.
|
||||
*
|
||||
* The element should be disabled when visit the form
|
||||
* then they should enable when trigger by a checkbox.
|
||||
*/
|
||||
public function doElementsDisabledStateTests(): void {
|
||||
$this->drupalGet('form-test/javascript-states-form');
|
||||
$session = $this->assertSession();
|
||||
|
||||
// The submit button should be disabled when visit the form.
|
||||
$button = $session->elementExists('css', 'input[value="Submit button disabled when checkbox not checked"]');
|
||||
$this->assertTrue($button->hasAttribute('disabled'));
|
||||
|
||||
// The submit button should be enabled when the checkbox is checked.
|
||||
$session->elementExists('css', 'input[name="checkbox_enable_submit_button"]')->check();
|
||||
$this->assertFalse($button->hasAttribute('disabled'));
|
||||
|
||||
// The text field should be disabled when visit the form.
|
||||
$textfield = $session->elementExists('css', 'input[name="input_textfield"]');
|
||||
$this->assertTrue($textfield->hasAttribute('disabled'));
|
||||
|
||||
// The text field should be enabled when the checkbox is checked.
|
||||
$session->elementExists('css', 'input[name="checkbox_enable_input_textfield"]')->check();
|
||||
$this->assertFalse($textfield->hasAttribute('disabled'));
|
||||
|
||||
// The select should be disabled when visit the form.
|
||||
$select = $session->elementExists('css', 'select[name="test_select_disabled"]');
|
||||
$this->assertTrue($select->hasAttribute('disabled'));
|
||||
|
||||
// The select should be enabled when the checkbox is checked.
|
||||
$session->elementExists('css', 'input[name="checkbox_enable_select"]')->check();
|
||||
$this->assertFalse($select->hasAttribute('disabled'));
|
||||
|
||||
// The textarea should be disabled when visit the form.
|
||||
$textarea = $session->elementExists('css', 'textarea[name="test_textarea_disabled"]');
|
||||
$this->assertTrue($textarea->hasAttribute('disabled'));
|
||||
|
||||
// The textarea should be enabled when the checkbox is checked.
|
||||
$session->elementExists('css', 'input[name="checkbox_enable_textarea"]')->check();
|
||||
$this->assertFalse($textarea->hasAttribute('disabled'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\js_message_test\Controller\JSMessageTestController;
|
||||
|
||||
/**
|
||||
* Tests core/drupal.message library.
|
||||
*
|
||||
* @group Javascript
|
||||
*/
|
||||
class JsMessageTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['js_message_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Enable the theme.
|
||||
\Drupal::service('theme_installer')->install(['test_messages']);
|
||||
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
|
||||
$theme_config->set('default', 'test_messages');
|
||||
$theme_config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests click on links to show messages and remove messages.
|
||||
*/
|
||||
public function testAddRemoveMessages(): void {
|
||||
$web_assert = $this->assertSession();
|
||||
$this->drupalGet('js_message_test_link');
|
||||
|
||||
$current_messages = [];
|
||||
foreach (JSMessageTestController::getMessagesSelectors() as $messagesSelector) {
|
||||
$web_assert->elementExists('css', $messagesSelector);
|
||||
foreach (JSMessageTestController::getTypes() as $type) {
|
||||
$this->click('[id="add-' . $messagesSelector . '-' . $type . '"]');
|
||||
$selector = "$messagesSelector .messages.messages--$type";
|
||||
$msg_element = $web_assert->waitForElementVisible('css', $selector);
|
||||
$this->assertNotEmpty($msg_element, "Message element visible: $selector");
|
||||
$web_assert->elementContains('css', $selector, "This is a message of the type, $type. You be the judge of its importance.");
|
||||
$current_messages[$selector] = "This is a message of the type, $type. You be the judge of its importance.";
|
||||
$this->assertCurrentMessages($current_messages, $messagesSelector);
|
||||
}
|
||||
// Remove messages 1 by 1 and confirm the messages are expected.
|
||||
foreach (JSMessageTestController::getTypes() as $type) {
|
||||
$this->click('[id="remove-' . $messagesSelector . '-' . $type . '"]');
|
||||
$selector = "$messagesSelector .messages.messages--$type";
|
||||
// The message for this selector should not be on the page.
|
||||
unset($current_messages[$selector]);
|
||||
$this->assertCurrentMessages($current_messages, $messagesSelector);
|
||||
}
|
||||
}
|
||||
|
||||
$messagesSelector = JSMessageTestController::getMessagesSelectors()[0];
|
||||
$current_messages = [];
|
||||
$types = JSMessageTestController::getTypes();
|
||||
$nb_messages = count($types) * 2;
|
||||
for ($i = 0; $i < $nb_messages; $i++) {
|
||||
$current_messages[] = "This is message number $i of the type, {$types[$i % count($types)]}. You be the judge of its importance.";
|
||||
}
|
||||
// Test adding multiple messages at once.
|
||||
// @see processMessages()
|
||||
$this->click('[id="add-multiple"]');
|
||||
$this->assertCurrentMessages($current_messages, $messagesSelector);
|
||||
$this->click('[id="remove-multiple"]');
|
||||
$this->assertCurrentMessages([], $messagesSelector);
|
||||
|
||||
$current_messages = [];
|
||||
for ($i = 0; $i < $nb_messages; $i++) {
|
||||
$current_messages[] = "Msg-$i";
|
||||
}
|
||||
// The last message is of a different type and shouldn't get cleared.
|
||||
$last_message = 'Msg-' . count($current_messages);
|
||||
$current_messages[] = $last_message;
|
||||
$this->click('[id="add-multiple-error"]');
|
||||
$this->assertCurrentMessages($current_messages, $messagesSelector);
|
||||
$this->click('[id="remove-type"]');
|
||||
$this->assertCurrentMessages([$last_message], $messagesSelector);
|
||||
$this->click('[id="clear-all"]');
|
||||
$this->assertCurrentMessages([], $messagesSelector);
|
||||
|
||||
// Confirm that when adding a message with an "id" specified but no status
|
||||
// that it receives the default status.
|
||||
$this->click('[id="id-no-status"]');
|
||||
$no_status_msg = 'Msg-id-no-status';
|
||||
$this->assertCurrentMessages([$no_status_msg], $messagesSelector);
|
||||
$web_assert->elementTextContains('css', "$messagesSelector .messages--status[data-drupal-message-id=\"my-special-id\"]", $no_status_msg);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that currently shown messages match expected messages.
|
||||
*
|
||||
* @param array $expected_messages
|
||||
* Expected messages.
|
||||
* @param string $messagesSelector
|
||||
* The css selector for the containing messages element.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertCurrentMessages(array $expected_messages, string $messagesSelector): void {
|
||||
$expected_messages = array_values($expected_messages);
|
||||
$current_messages = [];
|
||||
if ($message_divs = $this->getSession()->getPage()->findAll('css', "$messagesSelector .messages")) {
|
||||
foreach ($message_divs as $message_div) {
|
||||
/** @var \Behat\Mink\Element\NodeElement $message_div */
|
||||
$current_messages[] = $message_div->getText();
|
||||
}
|
||||
}
|
||||
// Check that each message text contains the expected text.
|
||||
if (count($expected_messages) !== count($current_messages)) {
|
||||
$this->fail('The expected messages array contains a different number of values than the current messages array.');
|
||||
}
|
||||
for ($i = 0; $i < count($expected_messages); $i++) {
|
||||
$this->assertStringContainsString($expected_messages[$i], $current_messages[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests for the machine name field.
|
||||
*
|
||||
* @group field
|
||||
*/
|
||||
class MachineNameTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* Required modules.
|
||||
*
|
||||
* Node is required because the machine name callback checks for
|
||||
* access_content.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $modules = ['node', 'form_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$account = $this->drupalCreateUser([
|
||||
'access content',
|
||||
]);
|
||||
$this->drupalLogin($account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that machine name field functions.
|
||||
*
|
||||
* Makes sure that the machine name field automatically provides a valid
|
||||
* machine name and that the manual editing mode functions.
|
||||
*/
|
||||
public function testMachineName(): void {
|
||||
// Visit the machine name test page which contains two machine name fields.
|
||||
$this->drupalGet('form-test/machine-name');
|
||||
|
||||
// Test values for conversion.
|
||||
$test_values = [
|
||||
[
|
||||
'input' => 'Test value !0-9@',
|
||||
'message' => 'A title that should be transliterated must be equal to the php generated machine name',
|
||||
'expected' => 'test_value_0_9',
|
||||
],
|
||||
[
|
||||
'input' => 'Test value',
|
||||
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
|
||||
'expected' => 'test_value',
|
||||
],
|
||||
[
|
||||
'input' => ' Test Value ',
|
||||
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
|
||||
'expected' => 'test_value',
|
||||
],
|
||||
[
|
||||
'input' => ', Neglect?! ',
|
||||
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
|
||||
'expected' => 'neglect',
|
||||
],
|
||||
[
|
||||
'input' => '0123456789!"$%&/()=?Test value?=)(/&%$"!9876543210',
|
||||
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
|
||||
'expected' => '0123456789_test_value_9876543210',
|
||||
],
|
||||
[
|
||||
'input' => '_Test_Value_',
|
||||
'message' => 'A title that should not be transliterated must be equal to the php generated machine name',
|
||||
'expected' => 'test_value',
|
||||
],
|
||||
];
|
||||
|
||||
// Get page and session.
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
// Get elements from the page.
|
||||
$title_1 = $page->findField('machine_name_1_label');
|
||||
$machine_name_1_field = $page->findField('machine_name_1');
|
||||
$machine_name_2_field = $page->findField('machine_name_2');
|
||||
$machine_name_1_wrapper = $machine_name_1_field->getParent();
|
||||
$machine_name_2_wrapper = $machine_name_2_field->getParent();
|
||||
$machine_name_1_value = $page->find('css', '#edit-machine-name-1-label-machine-name-suffix .machine-name-value');
|
||||
$machine_name_2_value = $page->find('css', '#edit-machine-name-2-label-machine-name-suffix .machine-name-value');
|
||||
$machine_name_3_value = $page->find('css', '#edit-machine-name-3-label-machine-name-suffix .machine-name-value');
|
||||
$button_1 = $page->find('css', '#edit-machine-name-1-label-machine-name-suffix button.link');
|
||||
|
||||
// Assert all fields are initialized correctly.
|
||||
$this->assertNotEmpty($machine_name_1_value, 'Machine name field 1 must be initialized');
|
||||
$this->assertNotEmpty($machine_name_2_value, 'Machine name field 2 must be initialized');
|
||||
$this->assertNotEmpty($machine_name_3_value, 'Machine name field 3 must be initialized');
|
||||
|
||||
// Assert that a machine name based on a default value is initialized.
|
||||
$this->assertJsCondition('jQuery("#edit-machine-name-3-label-machine-name-suffix .machine-name-value").html() == "yet_another_machine_name"');
|
||||
|
||||
// Test each value for conversion to a machine name.
|
||||
foreach ($test_values as $test_info) {
|
||||
// Set the value for the field, triggering the machine name update.
|
||||
$title_1->setValue($test_info['input']);
|
||||
|
||||
// Wait the set timeout for fetching the machine name.
|
||||
$this->assertJsCondition('jQuery("#edit-machine-name-1-label-machine-name-suffix .machine-name-value").html() == "' . $test_info['expected'] . '"');
|
||||
|
||||
// Validate the generated machine name.
|
||||
$this->assertEquals($test_info['expected'], $machine_name_1_value->getHtml(), $test_info['message']);
|
||||
|
||||
// Validate the second machine name field is empty.
|
||||
$this->assertEmpty($machine_name_2_value->getHtml(), 'The second machine name field should still be empty');
|
||||
}
|
||||
|
||||
// Validate the machine name field is hidden.
|
||||
$this->assertFalse($machine_name_1_wrapper->isVisible(), 'The ID field must not be visible');
|
||||
$this->assertFalse($machine_name_2_wrapper->isVisible(), 'The ID field must not be visible');
|
||||
|
||||
// Test switching back to the manual editing mode by clicking the edit link.
|
||||
$button_1->click();
|
||||
|
||||
// Validate the visibility of the machine name field.
|
||||
$this->assertTrue($machine_name_1_wrapper->isVisible(), 'The ID field must now be visible');
|
||||
|
||||
// Validate the visibility of the second machine name field.
|
||||
$this->assertFalse($machine_name_2_wrapper->isVisible(), 'The ID field must not be visible');
|
||||
|
||||
// Validate if the element contains the correct value.
|
||||
$this->assertEquals(end($test_values)['expected'], $machine_name_1_field->getValue(), 'The ID field value must be equal to the php generated machine name');
|
||||
|
||||
// Test that machine name generation still occurs after an HTML 5
|
||||
// validation failure.
|
||||
$this->drupalGet('form-test/machine-name');
|
||||
$this->assertSession()->buttonExists('Submit')->press();
|
||||
|
||||
// Assert all fields are initialized correctly.
|
||||
$this->assertNotEmpty($machine_name_1_value, 'Machine name field 1 must be initialized');
|
||||
$this->assertNotEmpty($machine_name_2_value, 'Machine name field 2 must be initialized');
|
||||
$this->assertNotEmpty($machine_name_3_value, 'Machine name field 3 must be initialized');
|
||||
|
||||
// Assert that a machine name based on a default value is initialized.
|
||||
$this->assertJsCondition('jQuery("#edit-machine-name-3-label-machine-name-suffix .machine-name-value").html() == "yet_another_machine_name"');
|
||||
|
||||
// Test each value for conversion to a machine name.
|
||||
foreach ($test_values as $test_info) {
|
||||
// Set the value for the field, triggering the machine name update.
|
||||
$title_1->setValue($test_info['input']);
|
||||
|
||||
// Wait the set timeout for fetching the machine name.
|
||||
$this->assertJsCondition('jQuery("#edit-machine-name-1-label-machine-name-suffix .machine-name-value").html() == "' . $test_info['expected'] . '"');
|
||||
|
||||
// Validate the generated machine name.
|
||||
$this->assertEquals($test_info['expected'], $machine_name_1_value->getHtml(), $test_info['message']);
|
||||
|
||||
// Validate the second machine name field is empty.
|
||||
$this->assertEmpty($machine_name_2_value->getHtml(), 'The second machine name field should still be empty');
|
||||
}
|
||||
|
||||
// Validate the machine name field is hidden. Elements are visually hidden
|
||||
// using positioning, isVisible() will therefore not work.
|
||||
$this->assertTrue($machine_name_1_wrapper->hasClass('hidden'), 'The ID field must not be visible');
|
||||
$this->assertTrue($machine_name_2_wrapper->hasClass('hidden'), 'The ID field must not be visible');
|
||||
|
||||
// Test switching back to the manual editing mode by clicking the edit link.
|
||||
$button_1->click();
|
||||
|
||||
// Validate the visibility of the machine name field.
|
||||
$this->assertFalse($machine_name_1_wrapper->hasClass('hidden'), 'The ID field must now be visible');
|
||||
|
||||
// Validate the visibility of the second machine name field.
|
||||
$this->assertTrue($machine_name_2_wrapper->hasClass('hidden'), 'The ID field must not be visible');
|
||||
|
||||
// Validate if the element contains the correct value.
|
||||
$this->assertEquals($test_values[1]['expected'], $machine_name_1_field->getValue(), 'The ID field value must be equal to the php generated machine name');
|
||||
|
||||
$assert = $this->assertSession();
|
||||
$this->drupalGet('/form-test/form-test-machine-name-validation');
|
||||
|
||||
// Test errors after with no AJAX.
|
||||
$assert->buttonExists('Save')->press();
|
||||
$assert->pageTextContains('Machine-readable name field is required.');
|
||||
// Ensure only the first machine name field has an error.
|
||||
$this->assertTrue($assert->fieldExists('id')->hasClass('error'));
|
||||
$this->assertFalse($assert->fieldExists('id2')->hasClass('error'));
|
||||
|
||||
// Test a successful submit after using AJAX.
|
||||
$assert->fieldExists('Name')->setValue('test 1');
|
||||
$machine_name_value = $page->find('css', '#edit-name-machine-name-suffix .machine-name-value');
|
||||
$this->assertNotEmpty($machine_name_value, 'Machine name field must be initialized');
|
||||
$this->assertJsCondition('jQuery("#edit-name-machine-name-suffix .machine-name-value").html() == "test_1"');
|
||||
|
||||
// Ensure that machine name generation still occurs after a non-HTML 5
|
||||
// validation failure.
|
||||
$this->assertEquals('test_1', $machine_name_value->getHtml(), $test_values[1]['message']);
|
||||
$machine_name_wrapper = $page->find('css', '#edit-id')->getParent();
|
||||
// Machine name field should not expand after failing validation.
|
||||
$this->assertTrue($machine_name_wrapper->hasClass('hidden'), 'The ID field must not be visible');
|
||||
$assert->selectExists('snack')->selectOption('apple');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->buttonExists('Save')->press();
|
||||
$assert->pageTextContains('The form_test_machine_name_validation_form form has been submitted successfully.');
|
||||
|
||||
// Test errors after using AJAX.
|
||||
$assert->fieldExists('Name')->setValue('duplicate');
|
||||
$this->assertJsCondition('document.forms[0].id.value === "duplicate"');
|
||||
$assert->fieldExists('id2')->setValue('duplicate2');
|
||||
$assert->selectExists('snack')->selectOption('potato');
|
||||
$assert->assertWaitOnAjaxRequest();
|
||||
$assert->buttonExists('Save')->press();
|
||||
$assert->pageTextContains('The machine-readable name is already in use. It must be unique.');
|
||||
// Ensure both machine name fields both have errors.
|
||||
$this->assertTrue($assert->fieldExists('id')->hasClass('error'));
|
||||
$this->assertTrue($assert->fieldExists('id2')->hasClass('error'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Core\Session;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\menu_link_content\Entity\MenuLinkContent;
|
||||
|
||||
/**
|
||||
* Tests that sessions don't expire.
|
||||
*
|
||||
* @group session
|
||||
*/
|
||||
class SessionTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['menu_link_content', 'block'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$account = $this->drupalCreateUser();
|
||||
$this->drupalLogin($account);
|
||||
|
||||
$menu_link_content = MenuLinkContent::create([
|
||||
'title' => 'Link to front page',
|
||||
'menu_name' => 'tools',
|
||||
'link' => ['uri' => 'route:<front>'],
|
||||
]);
|
||||
$menu_link_content->save();
|
||||
|
||||
$this->drupalPlaceBlock('system_menu_block:tools');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the session doesn't expire.
|
||||
*
|
||||
* Makes sure that drupal_valid_test_ua() works for multiple requests
|
||||
* performed by the Mink browser. The SIMPLETEST_USER_AGENT cookie must always
|
||||
* be valid.
|
||||
*/
|
||||
public function testSessionExpiration(): void {
|
||||
// Visit the front page and click the link back to the front page a large
|
||||
// number of times.
|
||||
$this->drupalGet('<front>');
|
||||
|
||||
$page = $this->getSession()->getPage();
|
||||
|
||||
for ($i = 0; $i < 25; $i++) {
|
||||
$page->clickLink('Link to front page');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Dialog;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
|
||||
|
||||
/**
|
||||
* Tests jQuery events deprecations.
|
||||
*
|
||||
* @group dialog
|
||||
*/
|
||||
class DialogDeprecationsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'block',
|
||||
'js_deprecation_test',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tests that the deprecation events are triggered.
|
||||
*/
|
||||
#[IgnoreDeprecations]
|
||||
public function testDialogDeprecations(): void {
|
||||
$this->drupalLogin($this->drupalCreateUser(['administer blocks']));
|
||||
$this->drupalGet('/admin/structure/block');
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$button = $assert_session->waitForElement('css', '[data-drupal-selector="edit-blocks-region-sidebar-first-title"]');
|
||||
$this->assertNotNull($button);
|
||||
$button->click();
|
||||
|
||||
$this->assertNotNull($assert_session->waitForElement('css', '.ui-dialog-content'));
|
||||
$this->getSession()->executeScript("window.jQuery('.ui-dialog-content').trigger('dialogButtonsChange');");
|
||||
$this->expectDeprecation('Javascript Deprecation: jQuery event dialogButtonsChange is deprecated in 11.2.0 and is removed from Drupal:12.0.0. See https://www.drupal.org/node/3464202');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Dialog;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests the JavaScript functionality of the dialog position.
|
||||
*
|
||||
* @group dialog
|
||||
*/
|
||||
class DialogPositionTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['block'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests if the dialog UI works properly with block layout page.
|
||||
*/
|
||||
public function testDialogOpenAndClose(): void {
|
||||
$admin_user = $this->drupalCreateUser(['administer blocks']);
|
||||
$this->drupalLogin($admin_user);
|
||||
$this->drupalGet('admin/structure/block');
|
||||
$session = $this->getSession();
|
||||
$assert_session = $this->assertSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
// Open the dialog using the place block link.
|
||||
$placeBlockLink = $page->findLink('Place block');
|
||||
$this->assertTrue($placeBlockLink->isVisible(), 'Place block button exists.');
|
||||
$placeBlockLink->click();
|
||||
$assert_session->assertWaitOnAjaxRequest();
|
||||
$dialog = $page->find('css', '.ui-dialog');
|
||||
$this->assertTrue($dialog->isVisible(), 'Dialog is opened after clicking the Place block button.');
|
||||
|
||||
// Close the dialog again.
|
||||
$closeButton = $page->find('css', '.ui-dialog-titlebar-close');
|
||||
$closeButton->click();
|
||||
$dialog = $page->find('css', '.ui-dialog');
|
||||
$this->assertNull($dialog, 'Dialog is closed after clicking the close button.');
|
||||
|
||||
// Resize the window. The test should pass after waiting for JavaScript to
|
||||
// finish as no Javascript errors should have been triggered. If there were
|
||||
// javascript errors the test will fail on that.
|
||||
$session->resizeWindow(625, 625);
|
||||
usleep(5000);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use Behat\Mink\Driver\Selenium2Driver;
|
||||
use Behat\Mink\Exception\DriverException;
|
||||
use WebDriver\Element;
|
||||
use WebDriver\Exception;
|
||||
use WebDriver\Exception\UnknownError;
|
||||
|
||||
/**
|
||||
* Provides a driver for Selenium testing.
|
||||
*/
|
||||
class DrupalSelenium2Driver extends Selenium2Driver {
|
||||
|
||||
/**
|
||||
* Uploads a file to the Selenium instance and returns the remote path.
|
||||
*
|
||||
* \Behat\Mink\Driver\Selenium2Driver::uploadFile() is a private method so
|
||||
* that can't be used inside a test, but we need the remote path that is
|
||||
* generated when uploading to make sure the file reference exists on the
|
||||
* container running selenium.
|
||||
*
|
||||
* @param string $path
|
||||
* The path to the file to upload.
|
||||
*
|
||||
* @return string
|
||||
* The remote path.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\DriverException
|
||||
* When PHP is compiled without zip support, or the file doesn't exist.
|
||||
* @throws \WebDriver\Exception\UnknownError
|
||||
* When an unknown error occurred during file upload.
|
||||
* @throws \Exception
|
||||
* When a known error occurred during file upload.
|
||||
*/
|
||||
public function uploadFileAndGetRemoteFilePath($path) {
|
||||
if (!is_file($path)) {
|
||||
throw new DriverException('File does not exist locally and cannot be uploaded to the remote instance.');
|
||||
}
|
||||
|
||||
if (!class_exists('ZipArchive')) {
|
||||
throw new DriverException('Could not compress file, PHP is compiled without zip support.');
|
||||
}
|
||||
|
||||
// Selenium only accepts uploads that are compressed as a Zip archive.
|
||||
$tempFilename = tempnam('', 'WebDriverZip');
|
||||
|
||||
$archive = new \ZipArchive();
|
||||
$result = $archive->open($tempFilename, \ZipArchive::OVERWRITE);
|
||||
if (!$result) {
|
||||
throw new DriverException('Zip archive could not be created. Error ' . $result);
|
||||
}
|
||||
$result = $archive->addFile($path, basename($path));
|
||||
if (!$result) {
|
||||
throw new DriverException('File could not be added to zip archive.');
|
||||
}
|
||||
$result = $archive->close();
|
||||
if (!$result) {
|
||||
throw new DriverException('Zip archive could not be closed.');
|
||||
}
|
||||
|
||||
try {
|
||||
$remotePath = $this->getWebDriverSession()->file(['file' => base64_encode(file_get_contents($tempFilename))]);
|
||||
|
||||
// If no path is returned the file upload failed silently.
|
||||
if (empty($remotePath)) {
|
||||
throw new UnknownError();
|
||||
}
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
finally {
|
||||
unlink($tempFilename);
|
||||
}
|
||||
|
||||
return $remotePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function click($xpath) {
|
||||
/** @var \Exception $not_clickable_exception */
|
||||
$not_clickable_exception = NULL;
|
||||
$result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath) {
|
||||
try {
|
||||
parent::click($xpath);
|
||||
return TRUE;
|
||||
}
|
||||
catch (Exception $exception) {
|
||||
if (!JSWebAssert::isExceptionNotClickable($exception)) {
|
||||
// Rethrow any unexpected exceptions.
|
||||
throw $exception;
|
||||
}
|
||||
$not_clickable_exception = $exception;
|
||||
return NULL;
|
||||
}
|
||||
});
|
||||
if ($result !== TRUE) {
|
||||
throw $not_clickable_exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function setValue($xpath, $value) {
|
||||
/** @var \Exception $not_clickable_exception */
|
||||
$not_clickable_exception = NULL;
|
||||
$result = $this->waitFor(10, function () use (&$not_clickable_exception, $xpath, $value) {
|
||||
try {
|
||||
$element = $this->getWebDriverSession()->element('xpath', $xpath);
|
||||
// \Behat\Mink\Driver\Selenium2Driver::setValue() will call .blur() on
|
||||
// the element, modify that to trigger the "input" and "change" events
|
||||
// instead. They indicate the value has changed, rather than implying
|
||||
// user focus changes. This script only runs when Drupal javascript has
|
||||
// been loaded.
|
||||
$this->executeJsOnElement($element, <<<JS
|
||||
if (typeof Drupal !== 'undefined') {
|
||||
var node = {{ELEMENT}};
|
||||
var original = node.blur;
|
||||
node.blur = function() {
|
||||
node.dispatchEvent(new Event("input", {bubbles:true}));
|
||||
node.dispatchEvent(new Event("change", {bubbles:true}));
|
||||
// Do not wait for the debounce, which only triggers the 'formUpdated` event
|
||||
// up to once every 0.3 seconds. In tests, no humans are typing, hence there
|
||||
// is no need to debounce.
|
||||
// @see Drupal.behaviors.formUpdated
|
||||
node.dispatchEvent(new Event("formUpdated", {bubbles:true}));
|
||||
node.blur = original;
|
||||
};
|
||||
}
|
||||
JS);
|
||||
if (!is_string($value) && strtolower($element->name()) === 'input' &&
|
||||
in_array(strtolower($element->attribute('type')), ['text', 'number', 'radio'], TRUE)) {
|
||||
// @todo Trigger deprecation in
|
||||
// https://www.drupal.org/project/drupal/issues/3421105.
|
||||
$value = (string) $value;
|
||||
}
|
||||
|
||||
parent::setValue($xpath, $value);
|
||||
return TRUE;
|
||||
}
|
||||
catch (Exception $exception) {
|
||||
if (!JSWebAssert::isExceptionNotClickable($exception) && !str_contains($exception->getMessage(), 'invalid element state')) {
|
||||
// Rethrow any unexpected exceptions.
|
||||
throw $exception;
|
||||
}
|
||||
$not_clickable_exception = $exception;
|
||||
return NULL;
|
||||
}
|
||||
});
|
||||
if ($result !== TRUE) {
|
||||
throw $not_clickable_exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a callback to return a truthy result and returns it.
|
||||
*
|
||||
* @param int|float $timeout
|
||||
* Maximal allowed waiting time in seconds.
|
||||
* @param callable $callback
|
||||
* Callback, which result is both used as waiting condition and returned.
|
||||
* Will receive reference to `this driver` as first argument.
|
||||
*
|
||||
* @return mixed
|
||||
* The result of the callback.
|
||||
*/
|
||||
private function waitFor($timeout, callable $callback) {
|
||||
$start = microtime(TRUE);
|
||||
$end = $start + $timeout;
|
||||
|
||||
do {
|
||||
$result = call_user_func($callback, $this);
|
||||
|
||||
if ($result) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(10000);
|
||||
} while (microtime(TRUE) < $end);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function dragTo($sourceXpath, $destinationXpath) {
|
||||
// Ensure both the source and destination exist at this point.
|
||||
$this->getWebDriverSession()->element('xpath', $sourceXpath);
|
||||
$this->getWebDriverSession()->element('xpath', $destinationXpath);
|
||||
|
||||
try {
|
||||
parent::dragTo($sourceXpath, $destinationXpath);
|
||||
}
|
||||
catch (Exception) {
|
||||
// Do not care if this fails for any reason. It is a source of random
|
||||
// fails. The calling code should be doing assertions on the results of
|
||||
// dragging anyway. See upstream issues:
|
||||
// - https://github.com/minkphp/MinkSelenium2Driver/issues/97
|
||||
// - https://github.com/minkphp/MinkSelenium2Driver/issues/51
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes JS on a given element.
|
||||
*
|
||||
* @param \WebDriver\Element $element
|
||||
* The webdriver element.
|
||||
* @param string $script
|
||||
* The script to execute.
|
||||
*
|
||||
* @return mixed
|
||||
* The result of executing the script.
|
||||
*/
|
||||
private function executeJsOnElement(Element $element, string $script) {
|
||||
$script = str_replace('{{ELEMENT}}', 'arguments[0]', $script);
|
||||
|
||||
$options = [
|
||||
'script' => $script,
|
||||
'args' => [$element],
|
||||
];
|
||||
|
||||
return $this->getWebDriverSession()->execute($options);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\EntityReference;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
|
||||
use Drupal\Core\Entity\Entity\EntityFormDisplay;
|
||||
use Drupal\entity_test\Entity\EntityTest;
|
||||
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
|
||||
use Drupal\Tests\node\Traits\NodeCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests the output of entity reference autocomplete widgets.
|
||||
*
|
||||
* @group entity_reference
|
||||
*/
|
||||
class EntityReferenceAutocompleteWidgetTest extends WebDriverTestBase {
|
||||
|
||||
use ContentTypeCreationTrait;
|
||||
use EntityReferenceFieldCreationTrait;
|
||||
use NodeCreationTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'node',
|
||||
'field_ui',
|
||||
'entity_test',
|
||||
'entity_reference_test',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Create a Content type and two test nodes.
|
||||
$this->createContentType(['type' => 'page']);
|
||||
$this->createNode(['title' => 'Test page']);
|
||||
$this->createNode(['title' => 'Page test']);
|
||||
|
||||
$user = $this->drupalCreateUser([
|
||||
'access content',
|
||||
'create page content',
|
||||
]);
|
||||
$this->drupalLogin($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the default autocomplete widget return the correct results.
|
||||
*/
|
||||
public function testEntityReferenceAutocompleteWidget(): void {
|
||||
/** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
|
||||
$display_repository = \Drupal::service('entity_display.repository');
|
||||
|
||||
// Create an entity reference field and use the default 'CONTAINS' match
|
||||
// operator.
|
||||
$field_name = 'field_test';
|
||||
$this->createEntityReferenceField(
|
||||
'node',
|
||||
'page',
|
||||
$field_name,
|
||||
$field_name,
|
||||
'node',
|
||||
'default',
|
||||
['target_bundles' => ['page'], 'sort' => ['field' => 'title', 'direction' => 'DESC']]);
|
||||
$form_display = $display_repository->getFormDisplay('node', 'page');
|
||||
$form_display->setComponent($field_name, [
|
||||
'type' => 'entity_reference_autocomplete',
|
||||
'settings' => [
|
||||
'match_operator' => 'CONTAINS',
|
||||
],
|
||||
]);
|
||||
// To satisfy config schema, the size setting must be an integer, not just
|
||||
// a numeric value. See https://www.drupal.org/node/2885441.
|
||||
$this->assertIsInt($form_display->getComponent($field_name)['settings']['size']);
|
||||
$form_display->save();
|
||||
$this->assertIsInt($form_display->getComponent($field_name)['settings']['size']);
|
||||
|
||||
// Visit the node add page.
|
||||
$this->drupalGet('node/add/page');
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$autocomplete_field = $assert_session->waitForElement('css', '[name="' . $field_name . '[0][target_id]"].ui-autocomplete-input');
|
||||
$autocomplete_field->setValue('Test');
|
||||
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
|
||||
$assert_session->waitOnAutocomplete();
|
||||
|
||||
$results = $page->findAll('css', '.ui-autocomplete li');
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$assert_session->pageTextContains('Test page');
|
||||
$assert_session->pageTextContains('Page test');
|
||||
|
||||
// Now switch the autocomplete widget to the 'STARTS_WITH' match operator.
|
||||
$display_repository->getFormDisplay('node', 'page')
|
||||
->setComponent($field_name, [
|
||||
'type' => 'entity_reference_autocomplete',
|
||||
'settings' => [
|
||||
'match_operator' => 'STARTS_WITH',
|
||||
],
|
||||
])
|
||||
->save();
|
||||
|
||||
$this->drupalGet('node/add/page');
|
||||
|
||||
$this->doAutocomplete($field_name);
|
||||
|
||||
$results = $page->findAll('css', '.ui-autocomplete li');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$assert_session->pageTextContains('Test page');
|
||||
$assert_session->pageTextNotContains('Page test');
|
||||
|
||||
// Change the size of the result set.
|
||||
$display_repository->getFormDisplay('node', 'page')
|
||||
->setComponent($field_name, [
|
||||
'type' => 'entity_reference_autocomplete',
|
||||
'settings' => [
|
||||
'match_limit' => 1,
|
||||
],
|
||||
])
|
||||
->save();
|
||||
|
||||
$this->drupalGet('node/add/page');
|
||||
|
||||
$this->doAutocomplete($field_name);
|
||||
$results = $page->findAll('css', '.ui-autocomplete li');
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$assert_session->pageTextContains('Test page');
|
||||
$assert_session->pageTextNotContains('Page test');
|
||||
|
||||
// Change the size of the result set via the UI.
|
||||
$this->drupalLogin($this->createUser([
|
||||
'access content',
|
||||
'administer content types',
|
||||
'administer node fields',
|
||||
'administer node form display',
|
||||
'create page content',
|
||||
]));
|
||||
$this->drupalGet('/admin/structure/types/manage/page/form-display');
|
||||
$assert_session->pageTextContains('Autocomplete suggestion list size: 1');
|
||||
// Click on the widget settings button to open the widget settings form.
|
||||
$this->submitForm([], $field_name . "_settings_edit");
|
||||
$this->assertSession()->waitForElement('css', sprintf('[name="fields[%s][settings_edit_form][settings][match_limit]"]', $field_name));
|
||||
$page->fillField('Number of results', 2);
|
||||
$page->pressButton('Save');
|
||||
$assert_session->pageTextContains('Your settings have been saved.');
|
||||
$assert_session->pageTextContains('Autocomplete suggestion list size: 2');
|
||||
|
||||
$this->drupalGet('node/add/page');
|
||||
|
||||
$this->doAutocomplete($field_name);
|
||||
$this->assertCount(2, $page->findAll('css', '.ui-autocomplete li'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the autocomplete widget knows about the entity its attached to.
|
||||
*
|
||||
* Ensures that the entity the autocomplete widget stores the entity it is
|
||||
* rendered on, and is available in the autocomplete results' AJAX request.
|
||||
*/
|
||||
public function testEntityReferenceAutocompleteWidgetAttachedEntity(): void {
|
||||
$user = $this->drupalCreateUser([
|
||||
'administer entity_test content',
|
||||
]);
|
||||
$this->drupalLogin($user);
|
||||
|
||||
$field_name = 'field_test';
|
||||
$this->createEntityReferenceField('entity_test', 'entity_test', $field_name, $field_name, 'entity_test', 'entity_test_all_except_host', ['target_bundles' => ['entity_test']]);
|
||||
$form_display = EntityFormDisplay::load('entity_test.entity_test.default');
|
||||
$form_display->setComponent($field_name, [
|
||||
'type' => 'entity_reference_autocomplete',
|
||||
'settings' => [
|
||||
'match_operator' => 'CONTAINS',
|
||||
],
|
||||
]);
|
||||
$form_display->save();
|
||||
|
||||
$host = EntityTest::create(['name' => 'dark green']);
|
||||
$host->save();
|
||||
EntityTest::create(['name' => 'dark blue'])->save();
|
||||
|
||||
$this->drupalGet($host->toUrl('edit-form'));
|
||||
|
||||
// Trigger the autocomplete.
|
||||
$page = $this->getSession()->getPage();
|
||||
$autocomplete_field = $page->findField($field_name . '[0][target_id]');
|
||||
$autocomplete_field->setValue('dark');
|
||||
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
|
||||
$this->assertSession()->waitOnAutocomplete();
|
||||
|
||||
// Check the autocomplete results.
|
||||
$results = $page->findAll('css', '.ui-autocomplete li');
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSession()->elementTextNotContains('css', '.ui-autocomplete li', 'dark green');
|
||||
$this->assertSession()->elementTextContains('css', '.ui-autocomplete li', 'dark blue');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an autocomplete on a given field and waits for it to finish.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The field name.
|
||||
*/
|
||||
protected function doAutocomplete($field_name): void {
|
||||
$autocomplete_field = $this->getSession()->getPage()->findField($field_name . '[0][target_id]');
|
||||
$autocomplete_field->setValue('Test');
|
||||
$this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
|
||||
$this->assertSession()->waitOnAutocomplete();
|
||||
}
|
||||
|
||||
}
|
||||
750
web/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php
Normal file
750
web/core/tests/Drupal/FunctionalJavascriptTests/JSWebAssert.php
Normal file
@ -0,0 +1,750 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use Behat\Mink\Element\Element;
|
||||
use Behat\Mink\Element\NodeElement;
|
||||
use Behat\Mink\Exception\ElementHtmlException;
|
||||
use Behat\Mink\Exception\ElementNotFoundException;
|
||||
use Behat\Mink\Exception\UnsupportedDriverActionException;
|
||||
use Drupal\Tests\WebAssert;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Constraint\IsNull;
|
||||
use PHPUnit\Framework\Constraint\LogicalNot;
|
||||
use WebDriver\Exception;
|
||||
|
||||
// cspell:ignore interactable xmlhttprequest
|
||||
|
||||
/**
|
||||
* Defines a class with methods for asserting presence of elements during tests.
|
||||
*/
|
||||
class JSWebAssert extends WebAssert {
|
||||
|
||||
/**
|
||||
* Waits for AJAX request to be completed.
|
||||
*
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
* @param string $message
|
||||
* (optional) A message for exception.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* When the request is not completed. If left blank, a default message will
|
||||
* be displayed.
|
||||
*/
|
||||
public function assertWaitOnAjaxRequest($timeout = 10000, $message = 'Unable to complete AJAX request.'): void {
|
||||
$this->assertExpectedAjaxRequest(NULL, $timeout, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that an AJAX request has been completed.
|
||||
*
|
||||
* @param int|null $count
|
||||
* (Optional) The number of completed AJAX requests expected.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
* @param string $message
|
||||
* (optional) A message for exception.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* When the request is not completed. If left blank, a default message will
|
||||
* be displayed.
|
||||
*/
|
||||
public function assertExpectedAjaxRequest(?int $count = NULL, $timeout = 10000, $message = 'Unable to complete AJAX request.'): void {
|
||||
// Wait for a very short time to allow page state to update after clicking.
|
||||
usleep(5000);
|
||||
$condition = <<<JS
|
||||
(function() {
|
||||
function isAjaxing(instance) {
|
||||
return instance && instance.ajaxing === true;
|
||||
}
|
||||
return (
|
||||
// Assert at least one AJAX request was started and completed.
|
||||
// For example, the machine name UI component does not use the Drupal
|
||||
// AJAX system, which means the other two checks below are inadequate.
|
||||
// @see Drupal.behaviors.machineName
|
||||
window.drupalActiveXhrCount === 0 && window.drupalCumulativeXhrCount >= 1 &&
|
||||
// Assert no AJAX request is running (via jQuery or Drupal) and no
|
||||
// animation is running.
|
||||
(typeof jQuery === 'undefined' || (jQuery.active === 0 && jQuery(':animated').length === 0)) &&
|
||||
(typeof Drupal === 'undefined' || typeof Drupal.ajax === 'undefined' || !Drupal.ajax.instances.some(isAjaxing))
|
||||
);
|
||||
}())
|
||||
JS;
|
||||
$completed = $this->session->wait($timeout, $condition);
|
||||
|
||||
// Now that there definitely is no more AJAX request in progress, count the
|
||||
// number of AJAX responses.
|
||||
// @see core/modules/system/tests/modules/js_testing_ajax_request_test/js/js_testing_ajax_request_test.js
|
||||
// @see https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin
|
||||
[$drupal_ajax_request_count, $browser_xhr_request_count, $page_hash] = $this->session->evaluateScript(<<<JS
|
||||
(function(){
|
||||
return [
|
||||
window.drupalCumulativeXhrCount,
|
||||
window.performance
|
||||
.getEntries()
|
||||
.filter(entry => entry.initiatorType === 'xmlhttprequest')
|
||||
.length,
|
||||
window.performance.timeOrigin
|
||||
];
|
||||
})()
|
||||
JS);
|
||||
|
||||
// First invocation of ::assertWaitOnAjaxRequest() on this page: initialize.
|
||||
static $current_page_hash;
|
||||
static $current_page_ajax_response_count;
|
||||
if ($current_page_hash !== $page_hash) {
|
||||
$current_page_hash = $page_hash;
|
||||
$current_page_ajax_response_count = 0;
|
||||
}
|
||||
|
||||
// Detect unnecessary AJAX request waits.
|
||||
if ($drupal_ajax_request_count === $current_page_ajax_response_count) {
|
||||
throw new \RuntimeException('There are no AJAX requests to wait for.');
|
||||
}
|
||||
|
||||
// Detect untracked AJAX requests. This will alert if the detection is
|
||||
// failing to provide an accurate count of requests.
|
||||
// @see core/modules/system/tests/modules/js_testing_ajax_request_test/js/js_testing_ajax_request_test.js
|
||||
if (!is_null($count) && $drupal_ajax_request_count !== $browser_xhr_request_count) {
|
||||
throw new \RuntimeException(sprintf('%d XHR requests through jQuery, but %d observed in the browser — this requires js_testing_ajax_request_test.js to be updated.', $drupal_ajax_request_count, $browser_xhr_request_count));
|
||||
}
|
||||
|
||||
// Detect incomplete AJAX request.
|
||||
if (!$completed) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
// Update the static variable for the next invocation, to allow detecting
|
||||
// unnecessary invocations.
|
||||
$current_page_ajax_response_count = $drupal_ajax_request_count;
|
||||
|
||||
if (!is_null($count)) {
|
||||
Assert::assertSame($count, $drupal_ajax_request_count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the specified selector and returns it when available.
|
||||
*
|
||||
* @param string $selector
|
||||
* The selector engine name. See ElementInterface::findAll() for the
|
||||
* supported selectors.
|
||||
* @param string|array $locator
|
||||
* The selector locator.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found, NULL if not.
|
||||
*
|
||||
* @see \Behat\Mink\Element\ElementInterface::findAll()
|
||||
*/
|
||||
public function waitForElement($selector, $locator, $timeout = 10000) {
|
||||
return $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
|
||||
return $page->find($selector, $locator);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for the specified selector and returns TRUE when it is unavailable.
|
||||
*
|
||||
* @param string $selector
|
||||
* The selector engine name. See ElementInterface::findAll() for the
|
||||
* supported selectors.
|
||||
* @param string|array $locator
|
||||
* The selector locator.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if not found, FALSE if found.
|
||||
*
|
||||
* @see \Behat\Mink\Element\ElementInterface::findAll()
|
||||
*/
|
||||
public function waitForElementRemoved($selector, $locator, $timeout = 10000) {
|
||||
return (bool) $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
|
||||
return !$page->find($selector, $locator);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the specified selector and returns it when available and visible.
|
||||
*
|
||||
* @param string $selector
|
||||
* The selector engine name. See ElementInterface::findAll() for the
|
||||
* supported selectors.
|
||||
* @param string|array $locator
|
||||
* The selector locator.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found and visible, NULL if not.
|
||||
*
|
||||
* @see \Behat\Mink\Element\ElementInterface::findAll()
|
||||
*/
|
||||
public function waitForElementVisible($selector, $locator, $timeout = 10000) {
|
||||
return $this->waitForHelper($timeout, function (Element $page) use ($selector, $locator) {
|
||||
$element = $page->find($selector, $locator);
|
||||
if (!empty($element) && $element->isVisible()) {
|
||||
return $element;
|
||||
}
|
||||
return NULL;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the specified text and returns TRUE when it is available.
|
||||
*
|
||||
* @param string $text
|
||||
* The text to wait for.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if found, FALSE if not found.
|
||||
*/
|
||||
public function waitForText($text, $timeout = 10000) {
|
||||
return (bool) $this->waitForHelper($timeout, function (Element $page) use ($text) {
|
||||
$actual = preg_replace('/\s+/u', ' ', $page->getText());
|
||||
$regex = '/' . preg_quote($text, '/') . '/ui';
|
||||
return (bool) preg_match($regex, $actual);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps waits in a function to catch curl exceptions to continue waiting.
|
||||
*
|
||||
* @param int $timeout
|
||||
* Timeout in milliseconds.
|
||||
* @param callable $callback
|
||||
* Callback, which result is both used as waiting condition and returned.
|
||||
*
|
||||
* @return mixed
|
||||
* The result of $callback.
|
||||
*/
|
||||
private function waitForHelper(int $timeout, callable $callback) {
|
||||
return $this->session->getPage()->waitFor($timeout / 1000, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the button specified by the locator and returns it.
|
||||
*
|
||||
* @param string $locator
|
||||
* The button ID, value or alt string.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found, NULL if not.
|
||||
*/
|
||||
public function waitForButton($locator, $timeout = 10000) {
|
||||
return $this->waitForElement('named', ['button', $locator], $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a link with specified locator and returns it when available.
|
||||
*
|
||||
* @param string $locator
|
||||
* The link ID, title, text or image alt.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found, NULL if not.
|
||||
*/
|
||||
public function waitForLink($locator, $timeout = 10000) {
|
||||
return $this->waitForElement('named', ['link', $locator], $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a field with specified locator and returns it when available.
|
||||
*
|
||||
* @param string $locator
|
||||
* The input ID, name or label for the field (input, textarea, select).
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found, NULL if not.
|
||||
*/
|
||||
public function waitForField($locator, $timeout = 10000) {
|
||||
return $this->waitForElement('named', ['field', $locator], $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for an element by its id and returns it when available.
|
||||
*
|
||||
* @param string $id
|
||||
* The element ID.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement|null
|
||||
* The page element node if found, NULL if not.
|
||||
*/
|
||||
public function waitForId($id, $timeout = 10000) {
|
||||
return $this->waitForElement('named', ['id', $id], $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the jQuery autocomplete delay duration.
|
||||
*
|
||||
* @see https://api.jqueryui.com/autocomplete/#option-delay
|
||||
*/
|
||||
public function waitOnAutocomplete() {
|
||||
// Wait for the autocomplete to be visible.
|
||||
return $this->waitForElementVisible('css', '.ui-autocomplete li');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a node, or its specific corner, is visible in the viewport.
|
||||
*
|
||||
* Note: Always set the viewport size. This can be done in your test with
|
||||
* \Behat\Mink\Session->resizeWindow(). JavaScript tests in the default CI
|
||||
* environment use a viewport of 1024x768px.
|
||||
*
|
||||
* @param string $selector_type
|
||||
* The element selector type (css, xpath).
|
||||
* @param string|array $selector
|
||||
* The element selector. Note: the first found element is used.
|
||||
* @param bool|string $corner
|
||||
* (Optional) The corner to test:
|
||||
* topLeft, topRight, bottomRight, bottomLeft.
|
||||
* Or FALSE to check the complete element (default).
|
||||
* @param string $message
|
||||
* (optional) A message for the exception.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\ElementHtmlException
|
||||
* When the element doesn't exist.
|
||||
* @throws \Behat\Mink\Exception\ElementNotFoundException
|
||||
* When the element is not visible in the viewport.
|
||||
*/
|
||||
public function assertVisibleInViewport($selector_type, $selector, $corner = FALSE, $message = 'Element is not visible in the viewport.') {
|
||||
$node = $this->session->getPage()->find($selector_type, $selector);
|
||||
if ($node === NULL) {
|
||||
if (is_array($selector)) {
|
||||
$selector = implode(' ', $selector);
|
||||
}
|
||||
throw new ElementNotFoundException($this->session->getDriver(), 'element', $selector_type, $selector);
|
||||
}
|
||||
|
||||
// Check if the node is visible on the page, which is a prerequisite of
|
||||
// being visible in the viewport.
|
||||
if (!$node->isVisible()) {
|
||||
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
|
||||
}
|
||||
|
||||
$result = $this->checkNodeVisibilityInViewport($node, $corner);
|
||||
|
||||
if (!$result) {
|
||||
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a node, or its specific corner, is not visible in the viewport.
|
||||
*
|
||||
* Note: the node should exist in the page, otherwise this assertion fails.
|
||||
*
|
||||
* @param string $selector_type
|
||||
* The element selector type (css, xpath).
|
||||
* @param string|array $selector
|
||||
* The element selector. Note: the first found element is used.
|
||||
* @param bool|string $corner
|
||||
* (Optional) Corner to test: topLeft, topRight, bottomRight, bottomLeft.
|
||||
* Or FALSE to check the complete element (default).
|
||||
* @param string $message
|
||||
* (optional) A message for the exception.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\ElementHtmlException
|
||||
* When the element doesn't exist.
|
||||
* @throws \Behat\Mink\Exception\ElementNotFoundException
|
||||
* When the element is not visible in the viewport.
|
||||
*
|
||||
* @see \Drupal\FunctionalJavascriptTests\JSWebAssert::assertVisibleInViewport()
|
||||
*/
|
||||
public function assertNotVisibleInViewport($selector_type, $selector, $corner = FALSE, $message = 'Element is visible in the viewport.') {
|
||||
$node = $this->session->getPage()->find($selector_type, $selector);
|
||||
if ($node === NULL) {
|
||||
if (is_array($selector)) {
|
||||
$selector = implode(' ', $selector);
|
||||
}
|
||||
throw new ElementNotFoundException($this->session->getDriver(), 'element', $selector_type, $selector);
|
||||
}
|
||||
|
||||
$result = $this->checkNodeVisibilityInViewport($node, $corner);
|
||||
|
||||
if ($result) {
|
||||
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the visibility of a node, or its specific corner.
|
||||
*
|
||||
* @param \Behat\Mink\Element\NodeElement $node
|
||||
* A valid node.
|
||||
* @param bool|string $corner
|
||||
* (Optional) Corner to test: topLeft, topRight, bottomRight, bottomLeft.
|
||||
* Or FALSE to check the complete element (default).
|
||||
*
|
||||
* @return bool
|
||||
* Returns TRUE if the node is visible in the viewport, FALSE otherwise.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\UnsupportedDriverActionException
|
||||
* When an invalid corner specification is given.
|
||||
*/
|
||||
private function checkNodeVisibilityInViewport(NodeElement $node, $corner = FALSE): bool {
|
||||
$xpath = $node->getXpath();
|
||||
|
||||
// Build the JavaScript to test if the complete element or a specific corner
|
||||
// is in the viewport.
|
||||
switch ($corner) {
|
||||
case 'topLeft':
|
||||
$test_javascript_function = <<<JS
|
||||
function t(r, lx, ly) {
|
||||
return (
|
||||
r.top >= 0 &&
|
||||
r.top <= ly &&
|
||||
r.left >= 0 &&
|
||||
r.left <= lx
|
||||
)
|
||||
}
|
||||
JS;
|
||||
break;
|
||||
|
||||
case 'topRight':
|
||||
$test_javascript_function = <<<JS
|
||||
function t(r, lx, ly) {
|
||||
return (
|
||||
r.top >= 0 &&
|
||||
r.top <= ly &&
|
||||
r.right >= 0 &&
|
||||
r.right <= lx
|
||||
);
|
||||
}
|
||||
JS;
|
||||
break;
|
||||
|
||||
case 'bottomRight':
|
||||
$test_javascript_function = <<<JS
|
||||
function t(r, lx, ly) {
|
||||
return (
|
||||
r.bottom >= 0 &&
|
||||
r.bottom <= ly &&
|
||||
r.right >= 0 &&
|
||||
r.right <= lx
|
||||
);
|
||||
}
|
||||
JS;
|
||||
break;
|
||||
|
||||
case 'bottomLeft':
|
||||
$test_javascript_function = <<<JS
|
||||
function t(r, lx, ly) {
|
||||
return (
|
||||
r.bottom >= 0 &&
|
||||
r.bottom <= ly &&
|
||||
r.left >= 0 &&
|
||||
r.left <= lx
|
||||
);
|
||||
}
|
||||
JS;
|
||||
break;
|
||||
|
||||
case FALSE:
|
||||
$test_javascript_function = <<<JS
|
||||
function t(r, lx, ly) {
|
||||
return (
|
||||
r.top >= 0 &&
|
||||
r.left >= 0 &&
|
||||
r.bottom <= ly &&
|
||||
r.right <= lx
|
||||
);
|
||||
}
|
||||
JS;
|
||||
break;
|
||||
|
||||
// Throw an exception if an invalid corner parameter is given.
|
||||
default:
|
||||
throw new UnsupportedDriverActionException($corner, $this->session->getDriver());
|
||||
}
|
||||
|
||||
// Build the full JavaScript test. The shared logic gets the corner
|
||||
// specific test logic injected.
|
||||
$full_javascript_visibility_test = <<<JS
|
||||
(function(t){
|
||||
var w = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
n = d.evaluate("$xpath", d, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue,
|
||||
r = n.getBoundingClientRect(),
|
||||
lx = (w.innerWidth || e.clientWidth),
|
||||
ly = (w.innerHeight || e.clientHeight);
|
||||
|
||||
return t(r, lx, ly);
|
||||
}($test_javascript_function));
|
||||
JS;
|
||||
|
||||
// Check the visibility by injecting and executing the full JavaScript test
|
||||
// script in the page.
|
||||
return $this->session->evaluateScript($full_javascript_visibility_test);
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes if the raw text IS NOT found escaped on the loaded page.
|
||||
*
|
||||
* Raw text refers to the raw HTML that the page generated.
|
||||
*
|
||||
* @param string $raw
|
||||
* Raw (HTML) string to look for.
|
||||
*/
|
||||
public function assertNoEscaped($raw) {
|
||||
$this->responseNotContains($this->escapeHtml($raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes if the raw text IS found escaped on the loaded page.
|
||||
*
|
||||
* Raw text refers to the raw HTML that the page generated.
|
||||
*
|
||||
* @param string $raw
|
||||
* Raw (HTML) string to look for.
|
||||
*/
|
||||
public function assertEscaped($raw) {
|
||||
$this->responseContains($this->escapeHtml($raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML for testing.
|
||||
*
|
||||
* Drupal's Html::escape() uses the ENT_QUOTES flag with htmlspecialchars() to
|
||||
* escape both single and double quotes. With WebDriverTestBase testing the
|
||||
* browser is automatically converting " and ' to double and single
|
||||
* quotes respectively therefore we can not escape them when testing for
|
||||
* escaped HTML.
|
||||
*
|
||||
* @param string $raw
|
||||
* The raw string to escape.
|
||||
*
|
||||
* @return string
|
||||
* The string with escaped HTML.
|
||||
*
|
||||
* @see Drupal\Component\Utility\Html::escape()
|
||||
*/
|
||||
protected function escapeHtml($raw): string {
|
||||
return htmlspecialchars($raw, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no matching element exists on the page after a wait.
|
||||
*
|
||||
* @param string $selector_type
|
||||
* The element selector type (css, xpath).
|
||||
* @param string|array $selector
|
||||
* The element selector.
|
||||
* @param int $timeout
|
||||
* (optional) Timeout in milliseconds, defaults to 10000.
|
||||
* @param string $message
|
||||
* (optional) The exception message.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\ElementHtmlException
|
||||
* When an element still exists on the page.
|
||||
*/
|
||||
public function assertNoElementAfterWait($selector_type, $selector, $timeout = 10000, $message = 'Element exists on the page.') {
|
||||
$start = microtime(TRUE);
|
||||
$end = $start + ($timeout / 1000);
|
||||
$page = $this->session->getPage();
|
||||
|
||||
do {
|
||||
$node = $page->find($selector_type, $selector);
|
||||
if (empty($node)) {
|
||||
return;
|
||||
}
|
||||
usleep(100000);
|
||||
} while (microtime(TRUE) < $end);
|
||||
|
||||
throw new ElementHtmlException($message, $this->session->getDriver(), $node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an exception is due to an element not being clickable.
|
||||
*
|
||||
* @param \WebDriver\Exception $exception
|
||||
* The exception to check.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the exception is due to an element not being clickable,
|
||||
* interactable or visible.
|
||||
*/
|
||||
public static function isExceptionNotClickable(Exception $exception): bool {
|
||||
return (bool) preg_match('/not (clickable|interactable|visible)/', $exception->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a status message exists after wait.
|
||||
*
|
||||
* @param string|null $type
|
||||
* The optional message type: status, error, or warning.
|
||||
* @param int $timeout
|
||||
* Optional timeout in milliseconds, defaults to 10000.
|
||||
*/
|
||||
public function statusMessageExistsAfterWait(?string $type = NULL, int $timeout = 10000): void {
|
||||
$selector = $this->buildJavascriptStatusMessageSelector(NULL, $type);
|
||||
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
|
||||
if ($type) {
|
||||
$failure_message = sprintf('A status message of type "%s" does not appear on this page, but it should.', $type);
|
||||
}
|
||||
else {
|
||||
$failure_message = 'A status message does not appear on this page, but it should.';
|
||||
}
|
||||
// There is no Assert::isNotNull() method, so we make our own constraint.
|
||||
$constraint = new LogicalNot(new IsNull());
|
||||
Assert::assertThat($status_message_element, $constraint, $failure_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a status message does not exist after wait.
|
||||
*
|
||||
* @param string|null $type
|
||||
* The optional message type: status, error, or warning.
|
||||
* @param int $timeout
|
||||
* Optional timeout in milliseconds, defaults to 10000.
|
||||
*/
|
||||
public function statusMessageNotExistsAfterWait(?string $type = NULL, int $timeout = 10000): void {
|
||||
$selector = $this->buildJavascriptStatusMessageSelector(NULL, $type);
|
||||
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
|
||||
if ($type) {
|
||||
$failure_message = sprintf('A status message of type "%s" appears on this page, but it should not.', $type);
|
||||
}
|
||||
else {
|
||||
$failure_message = 'A status message appears on this page, but it should not.';
|
||||
}
|
||||
Assert::assertThat($status_message_element, Assert::isNull(), $failure_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a status message containing given string exists after wait.
|
||||
*
|
||||
* @param string $message
|
||||
* The partial message to assert.
|
||||
* @param string|null $type
|
||||
* The optional message type: status, error, or warning.
|
||||
* @param int $timeout
|
||||
* Optional timeout in milliseconds, defaults to 10000.
|
||||
*/
|
||||
public function statusMessageContainsAfterWait(string $message, ?string $type = NULL, int $timeout = 10000): void {
|
||||
$selector = $this->buildJavascriptStatusMessageSelector($message, $type);
|
||||
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
|
||||
if ($type) {
|
||||
$failure_message = sprintf('A status message of type "%s" containing "%s" does not appear on this page, but it should.', $type, $message);
|
||||
}
|
||||
else {
|
||||
$failure_message = sprintf('A status message containing "%s" does not appear on this page, but it should.', $type);
|
||||
}
|
||||
// There is no Assert::isNotNull() method, so we make our own constraint.
|
||||
$constraint = new LogicalNot(new IsNull());
|
||||
Assert::assertThat($status_message_element, $constraint, $failure_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no status message containing given string exists after wait.
|
||||
*
|
||||
* @param string $message
|
||||
* The partial message to assert.
|
||||
* @param string|null $type
|
||||
* The optional message type: status, error, or warning.
|
||||
* @param int $timeout
|
||||
* Optional timeout in milliseconds, defaults to 10000.
|
||||
*/
|
||||
public function statusMessageNotContainsAfterWait(string $message, ?string $type = NULL, int $timeout = 10000): void {
|
||||
$selector = $this->buildJavascriptStatusMessageSelector($message, $type);
|
||||
$status_message_element = $this->waitForElement('xpath', $selector, $timeout);
|
||||
if ($type) {
|
||||
$failure_message = sprintf('A status message of type "%s" containing "%s" appears on this page, but it should not.', $type, $message);
|
||||
}
|
||||
else {
|
||||
$failure_message = sprintf('A status message containing "%s" appears on this page, but it should not.', $message);
|
||||
}
|
||||
Assert::assertThat($status_message_element, Assert::isNull(), $failure_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a xpath selector for a message with given type and text.
|
||||
*
|
||||
* The selector is designed to work with the Drupal.theme.message
|
||||
* template defined in message.js in addition to status-messages.html.twig
|
||||
* in the system module.
|
||||
*
|
||||
* @param string|null $message
|
||||
* The optional message or partial message to assert.
|
||||
* @param string|null $type
|
||||
* The optional message type: status, error, or warning.
|
||||
*
|
||||
* @return string
|
||||
* The xpath selector for the message.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* Thrown when $type is not an allowed type.
|
||||
*/
|
||||
private function buildJavascriptStatusMessageSelector(?string $message = NULL, ?string $type = NULL): string {
|
||||
$allowed_types = [
|
||||
'status',
|
||||
'error',
|
||||
'warning',
|
||||
NULL,
|
||||
];
|
||||
if (!in_array($type, $allowed_types, TRUE)) {
|
||||
throw new \InvalidArgumentException(sprintf("If a status message type is specified, the allowed values are 'status', 'error', 'warning'. The value provided was '%s'.", $type));
|
||||
}
|
||||
|
||||
if ($type) {
|
||||
$class = 'messages--' . $type;
|
||||
}
|
||||
else {
|
||||
$class = 'messages__wrapper';
|
||||
}
|
||||
|
||||
if ($message) {
|
||||
$js_selector = $this->buildXPathQuery('//div[contains(@class, :class) and contains(., :message)]', [
|
||||
':class' => $class,
|
||||
':message' => $message,
|
||||
]);
|
||||
}
|
||||
else {
|
||||
$js_selector = $this->buildXPathQuery('//div[contains(@class, :class)]', [
|
||||
':class' => $class,
|
||||
]);
|
||||
}
|
||||
|
||||
// We select based on WebAssert::buildStatusMessageSelector() or the
|
||||
// js_selector we have just built.
|
||||
return $this->buildStatusMessageSelector($message, $type) . ' | ' . $js_selector;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function statusMessageContains(string $message, ?string $type = NULL): void {
|
||||
$selector = $this->buildStatusMessageSelector($message, $type);
|
||||
$this->waitForElement('xpath', $selector);
|
||||
parent::statusMessageContains($message, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function statusMessageNotContains(string $message, ?string $type = NULL): void {
|
||||
$selector = $this->buildStatusMessageSelector($message, $type);
|
||||
// Wait for a second for the message to not exist.
|
||||
$this->waitForHelper(1000, function (Element $page) use ($selector) {
|
||||
return !$page->find('xpath', $selector);
|
||||
});
|
||||
parent::statusMessageNotContains($message, $type);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Tests Javascript deprecation notices.
|
||||
*
|
||||
* @group javascript
|
||||
* @group legacy
|
||||
*/
|
||||
class JavascriptDeprecationTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['js_deprecation_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests Javascript deprecation notices.
|
||||
*/
|
||||
public function testJavascriptDeprecation(): void {
|
||||
$this->expectDeprecation('Javascript Deprecation: This function is deprecated for testing purposes.');
|
||||
$this->expectDeprecation('Javascript Deprecation: This property is deprecated for testing purposes.');
|
||||
$this->drupalGet('js_deprecation_test');
|
||||
// Ensure that deprecation message from previous page loads will be
|
||||
// detected.
|
||||
$this->drupalGet('user');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Tests that Drupal.throwError can be suppressed to allow a test to pass.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JavascriptErrorsSuppressionTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['js_errors_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $failOnJavascriptConsoleErrors = FALSE;
|
||||
|
||||
/**
|
||||
* Tests that JavaScript console errors can be suppressed.
|
||||
*/
|
||||
public function testJavascriptErrors(): void {
|
||||
// Visit page that will throw a JavaScript console error.
|
||||
$this->drupalGet('js_errors_test');
|
||||
// Ensure that errors from previous page loads will be
|
||||
// detected.
|
||||
$this->drupalGet('user');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
|
||||
/**
|
||||
* Tests that Drupal.throwError will cause a test failure.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JavascriptErrorsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['js_errors_test'];
|
||||
|
||||
/**
|
||||
* Tests that JavaScript console errors will result in a test failure.
|
||||
*/
|
||||
public function testJavascriptErrors(): void {
|
||||
// Visit page that will throw a JavaScript console error.
|
||||
$this->drupalGet('js_errors_test');
|
||||
// Ensure that errors from previous page loads will be
|
||||
// detected.
|
||||
$this->drupalGet('user');
|
||||
|
||||
$this->expectException(AssertionFailedError::class);
|
||||
$this->expectExceptionMessageMatches('/^Error: A manually thrown error/');
|
||||
|
||||
// Manually call the method under test, as it cannot be caught by PHPUnit
|
||||
// when triggered from assertPostConditions().
|
||||
$this->failOnJavaScriptErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests JavaScript console errors during asynchronous calls.
|
||||
*/
|
||||
public function testJavascriptErrorsAsync(): void {
|
||||
// Visit page that will throw a JavaScript console error in async context.
|
||||
$this->drupalGet('js_errors_async_test');
|
||||
// Ensure that errors from previous page loads will be detected.
|
||||
$this->drupalGet('user');
|
||||
|
||||
$this->expectException(AssertionFailedError::class);
|
||||
$this->expectExceptionMessageMatches('/^Error: An error thrown in async context./');
|
||||
|
||||
// Manually call the method under test, as it cannot be caught by PHPUnit
|
||||
// when triggered from assertPostConditions().
|
||||
$this->failOnJavaScriptErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the JavaScript error log to prevent this test failing for real.
|
||||
*
|
||||
* @postCondition
|
||||
*/
|
||||
public function clearErrorLog(): void {
|
||||
$this->getSession()->executeScript("sessionStorage.removeItem('js_testing_log_test.errors')");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Tests Drupal settings retrieval in WebDriverTestBase tests.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JavascriptGetDrupalSettingsTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['test_page_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests retrieval of Drupal settings.
|
||||
*
|
||||
* @see \Drupal\FunctionalJavascriptTests\WebDriverTestBase::getDrupalSettings()
|
||||
*/
|
||||
public function testGetDrupalSettings(): void {
|
||||
$this->drupalLogin($this->drupalCreateUser());
|
||||
$this->drupalGet('test-page');
|
||||
|
||||
// Check that we can read the JS settings.
|
||||
$js_settings = $this->getDrupalSettings();
|
||||
$this->assertSame('azAZ09();.,\\\/-_{}', $js_settings['test-setting']);
|
||||
|
||||
// Dynamically change the setting using JavaScript.
|
||||
$script = <<<EndOfScript
|
||||
(function () {
|
||||
drupalSettings['test-setting'] = 'foo';
|
||||
})();
|
||||
EndOfScript;
|
||||
|
||||
$this->getSession()->evaluateScript($script);
|
||||
|
||||
// Check that the setting has been changed.
|
||||
$js_settings = $this->getDrupalSettings();
|
||||
$this->assertSame('foo', $js_settings['test-setting']);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\MachineName;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
use Drupal\language\Entity\ConfigurableLanguage;
|
||||
|
||||
/**
|
||||
* Tests the machine name transliteration functionality.
|
||||
*
|
||||
* @group javascript
|
||||
* @group #slow
|
||||
*/
|
||||
class MachineNameTransliterationTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'language',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$admin_user = $this->drupalCreateUser([
|
||||
'administer site configuration',
|
||||
'administer languages',
|
||||
'access administration pages',
|
||||
'administer permissions',
|
||||
]);
|
||||
$this->drupalLogin($admin_user);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for machine name transliteration functionality.
|
||||
*
|
||||
* @dataProvider machineNameInputOutput
|
||||
*/
|
||||
public function testMachineNameTransliterations($langcode, $input, $output): void {
|
||||
$page = $this->getSession()->getPage();
|
||||
if ($langcode !== 'en') {
|
||||
ConfigurableLanguage::createFromLangcode($langcode)->save();
|
||||
}
|
||||
$this->config('system.site')->set('default_langcode', $langcode)->save();
|
||||
$this->rebuildContainer();
|
||||
|
||||
$this->drupalGet("/admin/people/roles/add");
|
||||
$page->find('css', '[data-drupal-selector="edit-label"]')->setValue($input);
|
||||
$this->assertSession()->elementTextEquals('css', 'span.machine-name-value', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for the testMachineNameTransliterations.
|
||||
*
|
||||
* @return array
|
||||
* An array of arrays, where each sub-array contains a language code,
|
||||
* input string, and the expected transliterated output string.
|
||||
*/
|
||||
public static function machineNameInputOutput(): array {
|
||||
return [
|
||||
// cSpell:disable
|
||||
['en', 'Bob', 'bob'],
|
||||
['en', 'Äwesome', 'awesome'],
|
||||
['de', 'Äwesome', 'aewesome'],
|
||||
['da', 'äöüåøhello', 'aouaaoehello'],
|
||||
['fr', 'ц', 'c'],
|
||||
['fr', 'ᐑ', 'wii'],
|
||||
// This test is not working with chromedriver as '𐌰𐌸' chars are not
|
||||
// accepted.
|
||||
// ['en', '𐌰𐌸', '__'],
|
||||
['en', 'Ä Ö Ü Å Ø äöüåøhello', 'a_o_u_a_o_aouaohello'],
|
||||
['de', 'Ä Ö Ü Å Ø äöüåøhello', 'ae_oe_ue_a_o_aeoeueaohello'],
|
||||
['de', ']URY&m_G^;', 'ury_m_g'],
|
||||
['da', 'Ä Ö Ü Å Ø äöüåøhello', 'a_o_u_aa_oe_aouaaoehello'],
|
||||
['kg', 'ц', 'ts'],
|
||||
['en', ' Hello Abventor! ', 'hello_abventor'],
|
||||
// cSpell:enable
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use Drupal\Core\Database\Database;
|
||||
use Drupal\Tests\PerformanceTestTrait;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Collects performance metrics.
|
||||
*
|
||||
* @ingroup testing
|
||||
*/
|
||||
abstract class PerformanceTestBase extends WebDriverTestBase {
|
||||
use PerformanceTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['performance_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->doSetUpTasks();
|
||||
\Drupal::service('module_installer')->uninstall(['automated_cron']);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function prepareEnvironment() {
|
||||
parent::prepareEnvironment();
|
||||
$db = Database::getConnection();
|
||||
$test_file_name = (new \ReflectionClass($this))->getFileName();
|
||||
$is_core_test = str_starts_with($test_file_name, DRUPAL_ROOT . DIRECTORY_SEPARATOR . 'core');
|
||||
if ($db->databaseType() !== 'mysql' && $is_core_test) {
|
||||
$this->markTestSkipped('Drupal core performance tests only run on MySQL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function installModulesFromClassProperty(ContainerInterface $container) {
|
||||
$this->doInstallModulesFromClassProperty($container);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getMinkDriverArgs(): string {
|
||||
return $this->doGetMinkDriverArgs();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Provides functions for simulating sort changes.
|
||||
*
|
||||
* Selenium uses ChromeDriver for FunctionalJavascript tests, but it does not
|
||||
* currently support HTML5 drag and drop. These methods manipulate the DOM.
|
||||
* This trait should be deprecated when the Chromium bug is fixed.
|
||||
*
|
||||
* @see https://www.drupal.org/project/drupal/issues/3078152
|
||||
*/
|
||||
trait SortableTestTrait {
|
||||
|
||||
/**
|
||||
* Define to provide any necessary callback following layout change.
|
||||
*
|
||||
* @param string $item
|
||||
* The HTML selector for the element to be moved.
|
||||
* @param string $from
|
||||
* The HTML selector for the previous container element.
|
||||
* @param null|string $to
|
||||
* The HTML selector for the target container.
|
||||
*/
|
||||
abstract protected function sortableUpdate($item, $from, $to = NULL);
|
||||
|
||||
/**
|
||||
* Simulates a drag on an element from one container to another.
|
||||
*
|
||||
* @param string $item
|
||||
* The HTML selector for the element to be moved.
|
||||
* @param string $from
|
||||
* The HTML selector for the previous container element.
|
||||
* @param null|string $to
|
||||
* The HTML selector for the target container.
|
||||
*/
|
||||
protected function sortableTo($item, $from, $to) {
|
||||
$item = addslashes($item);
|
||||
$from = addslashes($from);
|
||||
$to = addslashes($to);
|
||||
|
||||
$script = <<<JS
|
||||
(function (src, to) {
|
||||
var sourceElement = document.querySelector(src);
|
||||
var toElement = document.querySelector(to);
|
||||
|
||||
toElement.insertBefore(sourceElement, toElement.firstChild);
|
||||
})('{$item}', '{$to}')
|
||||
|
||||
JS;
|
||||
|
||||
$options = [
|
||||
'script' => $script,
|
||||
'args' => [],
|
||||
];
|
||||
|
||||
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
|
||||
$this->sortableUpdate($item, $from, $to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a drag moving an element after its sibling in the same container.
|
||||
*
|
||||
* @param string $item
|
||||
* The HTML selector for the element to be moved.
|
||||
* @param string $target
|
||||
* The HTML selector for the sibling element.
|
||||
* @param string $from
|
||||
* The HTML selector for the element container.
|
||||
*/
|
||||
protected function sortableAfter($item, $target, $from) {
|
||||
$item = addslashes($item);
|
||||
$target = addslashes($target);
|
||||
$from = addslashes($from);
|
||||
|
||||
$script = <<<JS
|
||||
(function (src, to) {
|
||||
var sourceElement = document.querySelector(src);
|
||||
var toElement = document.querySelector(to);
|
||||
|
||||
toElement.insertAdjacentElement('afterend', sourceElement);
|
||||
})('{$item}', '{$target}')
|
||||
|
||||
JS;
|
||||
|
||||
$options = [
|
||||
'script' => $script,
|
||||
'args' => [],
|
||||
];
|
||||
|
||||
$this->getSession()->getDriver()->getWebDriverSession()->execute($options);
|
||||
$this->sortableUpdate($item, $from);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,666 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\TableDrag;
|
||||
|
||||
use Behat\Mink\Element\NodeElement;
|
||||
use Behat\Mink\Exception\ExpectationException;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests draggable table.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class TableDragTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* Class used to verify that dragging operations are in execution.
|
||||
*/
|
||||
const DRAGGING_CSS_CLASS = 'tabledrag-test-dragging';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['tabledrag_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* The state service.
|
||||
*
|
||||
* @var \Drupal\Core\State\StateInterface
|
||||
*/
|
||||
protected $state;
|
||||
|
||||
/**
|
||||
* Xpath selector for finding tabledrag indentation elements in a table row.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected static $indentationXpathSelector = 'child::td[1]/*[contains(concat(" ", normalize-space(@class), " "), " js-indentation ")][contains(concat(" ", normalize-space(@class), " "), " indentation ")]';
|
||||
|
||||
/**
|
||||
* Xpath selector for finding the tabledrag changed marker.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected static $tabledragChangedXpathSelector = 'child::td[1]/abbr[contains(concat(" ", normalize-space(@class), " "), " tabledrag-changed ")]';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->state = $this->container->get('state');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests row weight switch.
|
||||
*/
|
||||
public function testRowWeightSwitch(): void {
|
||||
$this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
|
||||
|
||||
$this->drupalGet('tabledrag_test');
|
||||
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
$weight_select1 = $page->findField("table[1][weight]");
|
||||
$weight_select2 = $page->findField("table[2][weight]");
|
||||
$weight_select3 = $page->findField("table[3][weight]");
|
||||
|
||||
// Check that rows weight selects are hidden.
|
||||
$this->assertFalse($weight_select1->isVisible());
|
||||
$this->assertFalse($weight_select2->isVisible());
|
||||
$this->assertFalse($weight_select3->isVisible());
|
||||
|
||||
// Toggle row weight selects as visible.
|
||||
$this->findWeightsToggle('Show row weights')->click();
|
||||
|
||||
// Check that rows weight selects are visible.
|
||||
$this->assertTrue($weight_select1->isVisible());
|
||||
$this->assertTrue($weight_select2->isVisible());
|
||||
$this->assertTrue($weight_select3->isVisible());
|
||||
|
||||
// Toggle row weight selects back to hidden.
|
||||
$this->findWeightsToggle('Hide row weights')->click();
|
||||
|
||||
// Check that rows weight selects are hidden again.
|
||||
$this->assertFalse($weight_select1->isVisible());
|
||||
$this->assertFalse($weight_select2->isVisible());
|
||||
$this->assertFalse($weight_select3->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests draggable table drag'n'drop.
|
||||
*/
|
||||
public function testDragAndDrop(): void {
|
||||
$this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
|
||||
$this->drupalGet('tabledrag_test');
|
||||
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
// Confirm touchevents detection is loaded with Tabledrag
|
||||
$this->assertNotNull($this->assertSession()->waitForElement('css', 'html.no-touchevents'));
|
||||
$weight_select1 = $page->findField("table[1][weight]");
|
||||
$weight_select2 = $page->findField("table[2][weight]");
|
||||
$weight_select3 = $page->findField("table[3][weight]");
|
||||
|
||||
// Check that initially the rows are in the correct order.
|
||||
$this->assertOrder(['Row with id 1', 'Row with id 2', 'Row with id 3']);
|
||||
|
||||
// Check that the 'unsaved changes' text is not present in the message area.
|
||||
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
|
||||
|
||||
$row1 = $this->findRowById(1)->find('css', 'a.tabledrag-handle');
|
||||
$row2 = $this->findRowById(2)->find('css', 'a.tabledrag-handle');
|
||||
$row3 = $this->findRowById(3)->find('css', 'a.tabledrag-handle');
|
||||
|
||||
// Drag row1 over row2.
|
||||
$row1->dragTo($row2);
|
||||
|
||||
// Check that the 'unsaved changes' text was added in the message area.
|
||||
$this->assertSession()->waitForText('You have unsaved changes.');
|
||||
|
||||
// Check that row1 and row2 were swapped.
|
||||
$this->assertOrder(['Row with id 2', 'Row with id 1', 'Row with id 3']);
|
||||
|
||||
// Check that weights were changed.
|
||||
$this->assertGreaterThan($weight_select2->getValue(), $weight_select1->getValue());
|
||||
$this->assertGreaterThan($weight_select2->getValue(), $weight_select3->getValue());
|
||||
$this->assertGreaterThan($weight_select1->getValue(), $weight_select3->getValue());
|
||||
|
||||
// Now move the last row (row3) in the second position. row1 should go last.
|
||||
$row3->dragTo($row1);
|
||||
|
||||
// Check that the order is: row2, row3 and row1.
|
||||
$this->assertOrder(['Row with id 2', 'Row with id 3', 'Row with id 1']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests accessibility through keyboard of the tabledrag functionality.
|
||||
*/
|
||||
public function testKeyboardAccessibility(): void {
|
||||
$this->assertKeyboardAccessibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts accessibility through keyboard of a test draggable table.
|
||||
*
|
||||
* @param string $drupal_path
|
||||
* The drupal path where the '#tabledrag-test-table' test table is present.
|
||||
* Defaults to 'tabledrag_test'.
|
||||
* @param array|null $structure
|
||||
* The expected table structure. If this isn't specified or equals NULL,
|
||||
* then the expected structure will be set by this method. Defaults to NULL.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertKeyboardAccessibility(string $drupal_path = 'tabledrag_test', ?array $structure = NULL): void {
|
||||
$expected_table = $structure ?: [
|
||||
['id' => '1', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '2', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '3', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '4', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '5', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
];
|
||||
if (!empty($drupal_path)) {
|
||||
$this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
|
||||
$this->drupalGet($drupal_path);
|
||||
}
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nest the row with id 2 as child of row 1.
|
||||
$this->moveRowWithKeyboard($this->findRowById(2), 'right');
|
||||
$expected_table[1] = ['id' => '2', 'weight' => -10, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nest the row with id 3 as child of row 1.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
|
||||
$expected_table[2] = ['id' => '3', 'weight' => -9, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nest the row with id 3 as child of row 2.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
|
||||
$expected_table[2] = ['id' => '3', 'weight' => -10, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nesting should be allowed to maximum level 2.
|
||||
$this->moveRowWithKeyboard($this->findRowById(4), 'right', 4);
|
||||
$expected_table[3] = ['id' => '4', 'weight' => -9, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Re-order children of row 1.
|
||||
$this->moveRowWithKeyboard($this->findRowById(4), 'up');
|
||||
$expected_table[2] = ['id' => '4', 'weight' => -10, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
|
||||
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '2', 'indentation' => 2, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Move back the row 3 to the 1st level.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'left');
|
||||
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '1', 'indentation' => 1, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'left');
|
||||
$expected_table[0] = ['id' => '1', 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
|
||||
$expected_table[3] = ['id' => '3', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
|
||||
$expected_table[4] = ['id' => '5', 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Move row 3 to the last position.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'down');
|
||||
$expected_table[3] = ['id' => '5', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
|
||||
$expected_table[4] = ['id' => '3', 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nothing happens when trying to move the last row further down.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'down');
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Nest row 3 under 5. The max depth allowed should be 1.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'right', 3);
|
||||
$expected_table[4] = ['id' => '3', 'weight' => -10, 'parent' => '5', 'indentation' => 1, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// The first row of the table cannot be nested.
|
||||
$this->moveRowWithKeyboard($this->findRowById(1), 'right');
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Move a row which has nested children. The children should move with it,
|
||||
// with nesting preserved. Swap the order of the top-level rows by moving
|
||||
// row 1 to after row 3.
|
||||
$this->moveRowWithKeyboard($this->findRowById(1), 'down', 2);
|
||||
$expected_table[0] = ['id' => '5', 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
|
||||
$expected_table[3] = $expected_table[1];
|
||||
$expected_table[1] = $expected_table[4];
|
||||
$expected_table[4] = $expected_table[2];
|
||||
$expected_table[2] = ['id' => '1', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the root and leaf behaviors for rows.
|
||||
*/
|
||||
public function testRootLeafDraggableRowsWithKeyboard(): void {
|
||||
$this->state->set('tabledrag_test_table', [
|
||||
1 => [],
|
||||
2 => ['parent' => 1, 'depth' => 1, 'classes' => ['tabledrag-leaf']],
|
||||
3 => ['parent' => 1, 'depth' => 1],
|
||||
4 => [],
|
||||
5 => ['classes' => ['tabledrag-root']],
|
||||
]);
|
||||
|
||||
$this->drupalGet('tabledrag_test');
|
||||
$expected_table = [
|
||||
['id' => '1', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '2', 'weight' => 0, 'parent' => '1', 'indentation' => 1, 'changed' => FALSE],
|
||||
['id' => '3', 'weight' => 0, 'parent' => '1', 'indentation' => 1, 'changed' => FALSE],
|
||||
['id' => '4', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
['id' => '5', 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
|
||||
];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Rows marked as root cannot be moved as children of another row.
|
||||
$this->moveRowWithKeyboard($this->findRowById(5), 'right');
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Rows marked as leaf cannot have children. Trying to move the row #3
|
||||
// as child of #2 should have no results.
|
||||
$this->moveRowWithKeyboard($this->findRowById(3), 'right');
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Leaf can be still swapped and moved to first level.
|
||||
$this->moveRowWithKeyboard($this->findRowById(2), 'down');
|
||||
$this->moveRowWithKeyboard($this->findRowById(2), 'left');
|
||||
$expected_table[0]['weight'] = -10;
|
||||
$expected_table[1]['id'] = '3';
|
||||
$expected_table[1]['weight'] = -10;
|
||||
$expected_table[2] = ['id' => '2', 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
|
||||
$expected_table[3]['weight'] = -8;
|
||||
$expected_table[4]['weight'] = -7;
|
||||
$this->assertDraggableTable($expected_table);
|
||||
|
||||
// Root rows can have children.
|
||||
$this->moveRowWithKeyboard($this->findRowById(4), 'down');
|
||||
$this->moveRowWithKeyboard($this->findRowById(4), 'right');
|
||||
$expected_table[3]['id'] = '5';
|
||||
$expected_table[4] = ['id' => '4', 'weight' => -10, 'parent' => '5', 'indentation' => 1, 'changed' => TRUE];
|
||||
$this->assertDraggableTable($expected_table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the warning that appears upon making changes to a tabledrag table.
|
||||
*/
|
||||
public function testTableDragChangedWarning(): void {
|
||||
$this->drupalGet('tabledrag_test');
|
||||
|
||||
// By default no text is visible.
|
||||
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
|
||||
// Try to make a non-allowed action, like moving further down the last row.
|
||||
// No changes happen, so no message should be shown.
|
||||
$this->moveRowWithKeyboard($this->findRowById(5), 'down');
|
||||
$this->assertSession()->pageTextNotContains('You have unsaved changes.');
|
||||
|
||||
// Make a change. The message will appear.
|
||||
$this->moveRowWithKeyboard($this->findRowById(5), 'right');
|
||||
$this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
|
||||
|
||||
// Make another change, the text will stay visible and appear only once.
|
||||
$this->moveRowWithKeyboard($this->findRowById(2), 'up');
|
||||
$this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that several pieces of markup are in a given order in the page.
|
||||
*
|
||||
* @param string[] $items
|
||||
* An ordered list of strings.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\ExpectationException
|
||||
* When any of the given string is not found.
|
||||
*
|
||||
* @todo Remove this and use the WebAssert method when #2817657 is done.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertOrder(array $items): void {
|
||||
$session = $this->getSession();
|
||||
$text = $session->getPage()->getHtml();
|
||||
$strings = [];
|
||||
foreach ($items as $item) {
|
||||
if (($pos = strpos($text, $item)) === FALSE) {
|
||||
throw new ExpectationException("Cannot find '$item' in the page", $session->getDriver());
|
||||
}
|
||||
$strings[$pos] = $item;
|
||||
}
|
||||
ksort($strings);
|
||||
$this->assertSame($items, array_values($strings), "Strings found on the page but incorrectly ordered.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests nested draggable tables through keyboard.
|
||||
*/
|
||||
public function testNestedDraggableTables(): void {
|
||||
$this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
|
||||
$this->drupalGet('tabledrag_test_nested');
|
||||
$this->assertKeyboardAccessibility('');
|
||||
|
||||
// Now move the rows of the parent table.
|
||||
$expected_parent_table = [
|
||||
[
|
||||
'id' => 'parent_1',
|
||||
'weight' => 0,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
[
|
||||
'id' => 'parent_2',
|
||||
'weight' => 0,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
[
|
||||
'id' => 'parent_3',
|
||||
'weight' => 0,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
];
|
||||
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
|
||||
|
||||
// Switch parent table rows children.
|
||||
$this->moveRowWithKeyboard($this->findRowById('parent_2', 'tabledrag-test-parent-table'), 'up');
|
||||
$expected_parent_table = [
|
||||
[
|
||||
'id' => 'parent_2',
|
||||
'weight' => -10,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
[
|
||||
'id' => 'parent_1',
|
||||
'weight' => -9,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
[
|
||||
'id' => 'parent_3',
|
||||
'weight' => -8,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
];
|
||||
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
|
||||
|
||||
// Try to move the row that contains the nested table to the last position.
|
||||
// Order should be changed, but changed marker isn't added.
|
||||
// This seems to be buggy, but this is the original behavior.
|
||||
$this->moveRowWithKeyboard($this->findRowById('parent_1', 'tabledrag-test-parent-table'), 'down');
|
||||
$expected_parent_table = [
|
||||
[
|
||||
'id' => 'parent_2',
|
||||
'weight' => -10,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
[
|
||||
'id' => 'parent_3',
|
||||
'weight' => -9,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
// Since 'parent_1' row was moved, it should be marked as changed, but
|
||||
// this would fail with core tabledrag.js.
|
||||
[
|
||||
'id' => 'parent_1',
|
||||
'weight' => -8,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => NULL,
|
||||
],
|
||||
];
|
||||
$this->assertDraggableTable($expected_parent_table, 'tabledrag-test-parent-table', TRUE);
|
||||
|
||||
// Re-test the nested draggable table.
|
||||
$expected_child_table_structure = [
|
||||
[
|
||||
'id' => '5',
|
||||
'weight' => -10,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => FALSE,
|
||||
],
|
||||
[
|
||||
'id' => '3',
|
||||
'weight' => -10,
|
||||
'parent' => '5',
|
||||
'indentation' => 1,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
[
|
||||
'id' => '1',
|
||||
'weight' => -9,
|
||||
'parent' => '',
|
||||
'indentation' => 0,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
[
|
||||
'id' => '2',
|
||||
'weight' => -10,
|
||||
'parent' => '1',
|
||||
'indentation' => 1,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
[
|
||||
'id' => '4',
|
||||
'weight' => -10,
|
||||
'parent' => '2',
|
||||
'indentation' => 2,
|
||||
'changed' => TRUE,
|
||||
],
|
||||
];
|
||||
$this->assertDraggableTable($expected_child_table_structure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the whole structure of the draggable test table.
|
||||
*
|
||||
* @param array $structure
|
||||
* The table structure. Each entry represents a row and consists of:
|
||||
* - id: the expected value for the ID hidden field.
|
||||
* - weight: the expected row weight.
|
||||
* - parent: the expected parent ID for the row.
|
||||
* - indentation: how many indents the row should have.
|
||||
* - changed: whether or not the row should have been marked as changed.
|
||||
* @param string $table_id
|
||||
* The ID of the table. Defaults to 'tabledrag-test-table'.
|
||||
* @param bool $skip_missing
|
||||
* Whether assertions done on missing elements value may be skipped or not.
|
||||
* Defaults to FALSE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertDraggableTable(array $structure, string $table_id = 'tabledrag-test-table', bool $skip_missing = FALSE): void {
|
||||
$rows = $this->getSession()->getPage()->findAll('xpath', "//table[@id='$table_id']/tbody/tr");
|
||||
$this->assertSession()->elementsCount('xpath', "//table[@id='$table_id']/tbody/tr", count($structure));
|
||||
|
||||
foreach ($structure as $delta => $expected) {
|
||||
$this->assertTableRow($rows[$delta], $expected['id'], $expected['weight'], $expected['parent'], $expected['indentation'], $expected['changed'], $skip_missing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the values of a draggable row.
|
||||
*
|
||||
* @param \Behat\Mink\Element\NodeElement $row
|
||||
* The row element to assert.
|
||||
* @param string $id
|
||||
* The expected value for the ID hidden input of the row.
|
||||
* @param int $weight
|
||||
* The expected weight of the row.
|
||||
* @param string $parent
|
||||
* The expected parent ID.
|
||||
* @param int $indentation
|
||||
* The expected indentation of the row.
|
||||
* @param bool|null $changed
|
||||
* Whether or not the row should have been marked as changed. NULL means
|
||||
* that this assertion should be skipped.
|
||||
* @param bool $skip_missing
|
||||
* Whether assertions done on missing elements value may be skipped or not.
|
||||
* Defaults to FALSE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertTableRow(NodeElement $row, string $id, int $weight, string $parent = '', int $indentation = 0, ?bool $changed = FALSE, bool $skip_missing = FALSE): void {
|
||||
// Assert that the row position is correct by checking that the id
|
||||
// corresponds.
|
||||
$id_name = "table[$id][id]";
|
||||
if (!$skip_missing || $row->find('hidden_field_selector', ['hidden_field', $id_name])) {
|
||||
$this->assertSession()->hiddenFieldValueEquals($id_name, $id, $row);
|
||||
}
|
||||
$parent_name = "table[$id][parent]";
|
||||
if (!$skip_missing || $row->find('hidden_field_selector', ['hidden_field', $parent_name])) {
|
||||
$this->assertSession()->hiddenFieldValueEquals($parent_name, $parent, $row);
|
||||
}
|
||||
$this->assertSession()->fieldValueEquals("table[$id][weight]", $weight, $row);
|
||||
$this->assertSession()->elementsCount('xpath', static::$indentationXpathSelector, $indentation, $row);
|
||||
// A row is marked as changed when the related markup is present.
|
||||
if ($changed !== NULL) {
|
||||
$this->assertSession()->elementsCount('xpath', static::$tabledragChangedXpathSelector, (int) $changed, $row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a row in the test table by the row ID.
|
||||
*
|
||||
* @param string|int $id
|
||||
* The ID of the row.
|
||||
* @param string $table_id
|
||||
* The ID of the parent table. Defaults to 'tabledrag-test-table'.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement
|
||||
* The row element.
|
||||
*/
|
||||
protected function findRowById($id, $table_id = 'tabledrag-test-table'): NodeElement {
|
||||
$xpath = "//table[@id='$table_id']/tbody/tr[.//input[@name='table[$id][id]']]";
|
||||
$row = $this->getSession()->getPage()->find('xpath', $xpath);
|
||||
$this->assertNotEmpty($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the show/hide weight toggle element.
|
||||
*
|
||||
* @param string $expected_text
|
||||
* The expected text on the element.
|
||||
*
|
||||
* @return \Behat\Mink\Element\NodeElement
|
||||
* The toggle element.
|
||||
*/
|
||||
protected function findWeightsToggle($expected_text): NodeElement {
|
||||
$toggle = $this->getSession()->getPage()->findButton($expected_text);
|
||||
$this->assertNotEmpty($toggle);
|
||||
return $toggle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a row through the keyboard.
|
||||
*
|
||||
* @param \Behat\Mink\Element\NodeElement $row
|
||||
* The row to move.
|
||||
* @param string $arrow
|
||||
* The arrow button to use to move the row. Either one of 'left', 'right',
|
||||
* 'up' or 'down'.
|
||||
* @param int $repeat
|
||||
* (optional) How many times to press the arrow button. Defaults to 1.
|
||||
*/
|
||||
protected function moveRowWithKeyboard(NodeElement $row, $arrow, $repeat = 1): void {
|
||||
$keys = [
|
||||
'left' => 37,
|
||||
'right' => 39,
|
||||
'up' => 38,
|
||||
'down' => 40,
|
||||
];
|
||||
if (!isset($keys[$arrow])) {
|
||||
throw new \InvalidArgumentException('The arrow parameter must be one of "left", "right", "up" or "down".');
|
||||
}
|
||||
|
||||
$key = $keys[$arrow];
|
||||
|
||||
$handle = $row->find('css', 'a.tabledrag-handle');
|
||||
$handle->focus();
|
||||
|
||||
for ($i = 0; $i < $repeat; $i++) {
|
||||
$this->markRowHandleForDragging($handle);
|
||||
$handle->keyDown($key);
|
||||
$handle->keyUp($key);
|
||||
$this->waitUntilDraggingCompleted($handle);
|
||||
}
|
||||
|
||||
$handle->blur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a row handle for dragging.
|
||||
*
|
||||
* The handle is marked by adding a css class that is removed by an helper
|
||||
* js library once the dragging is over.
|
||||
*
|
||||
* @param \Behat\Mink\Element\NodeElement $handle
|
||||
* The draggable row handle element.
|
||||
*
|
||||
* @throws \Exception
|
||||
* Thrown when the class is not added successfully to the handle.
|
||||
*/
|
||||
protected function markRowHandleForDragging(NodeElement $handle): void {
|
||||
$class = self::DRAGGING_CSS_CLASS;
|
||||
$script = <<<JS
|
||||
document.evaluate("{$handle->getXpath()}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
|
||||
.singleNodeValue.classList.add('{$class}');
|
||||
JS;
|
||||
|
||||
$this->getSession()->executeScript($script);
|
||||
$has_class = $this->getSession()->getPage()->waitFor(1, function () use ($handle, $class) {
|
||||
return $handle->hasClass($class);
|
||||
});
|
||||
|
||||
if (!$has_class) {
|
||||
throw new \Exception(sprintf('Dragging css class was not added on handle "%s".', $handle->getXpath()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the dragging operations are finished on a row handle.
|
||||
*
|
||||
* @param \Behat\Mink\Element\NodeElement $handle
|
||||
* The draggable row handle element.
|
||||
*
|
||||
* @throws \Exception
|
||||
* Thrown when the dragging operations are not completed on time.
|
||||
*/
|
||||
protected function waitUntilDraggingCompleted(NodeElement $handle): void {
|
||||
$class_removed = $this->getSession()->getPage()->waitFor(1, function () use ($handle) {
|
||||
return !$handle->hasClass($this::DRAGGING_CSS_CLASS);
|
||||
});
|
||||
|
||||
if (!$class_removed) {
|
||||
throw new \Exception(sprintf('Dragging operations did not complete on time on handle %s', $handle->getXpath()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Tests;
|
||||
|
||||
use Behat\Mink\Driver\Selenium2Driver;
|
||||
use Drupal\entity_test\Entity\EntityTest;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
|
||||
use Drupal\Tests\TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests the DrupalSelenium2Driver methods.
|
||||
*
|
||||
* @coversDefaultClass \Drupal\FunctionalJavascriptTests\DrupalSelenium2Driver
|
||||
* @group javascript
|
||||
*/
|
||||
class DrupalSelenium2DriverTest extends WebDriverTestBase {
|
||||
|
||||
use TestFileCreationTrait;
|
||||
use FileFieldCreationTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['file', 'field_ui', 'entity_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$storage_settings = ['cardinality' => 3];
|
||||
$this->createFileField('field_file', 'entity_test', 'entity_test', $storage_settings);
|
||||
$this->drupalLogin($this->drupalCreateUser([
|
||||
'administer entity_test content',
|
||||
'access content',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests uploading remote files.
|
||||
*/
|
||||
public function testGetRemoteFilePath(): void {
|
||||
$web_driver = $this->getSession()->getDriver();
|
||||
$this->assertInstanceOf(Selenium2Driver::class, $web_driver);
|
||||
|
||||
$file_system = \Drupal::service('file_system');
|
||||
$entity = EntityTest::create();
|
||||
$entity->save();
|
||||
|
||||
$files = array_slice($this->getTestFiles('text'), 0, 3);
|
||||
$real_paths = [];
|
||||
foreach ($files as $file) {
|
||||
$real_paths[] = $file_system->realpath($file->uri);
|
||||
}
|
||||
$remote_paths = [];
|
||||
foreach ($real_paths as $path) {
|
||||
$remote_paths[] = $web_driver->uploadFileAndGetRemoteFilePath($path);
|
||||
}
|
||||
|
||||
// Tests that uploading multiple remote files works with remote path.
|
||||
$this->drupalGet($entity->toUrl('edit-form'));
|
||||
$multiple_field = $this->assertSession()->elementExists('xpath', '//input[@multiple]');
|
||||
$multiple_field->setValue(implode("\n", $remote_paths));
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$this->getSession()->getPage()->findButton('Save')->click();
|
||||
$entity = EntityTest::load($entity->id());
|
||||
$this->assertCount(3, $entity->field_file);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Tests;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use WebDriver\Exception;
|
||||
|
||||
/**
|
||||
* Tests fault tolerant interactions.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JSInteractionTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'js_interaction_test',
|
||||
];
|
||||
|
||||
/**
|
||||
* Assert an exception is thrown when the blocker element is never removed.
|
||||
*/
|
||||
public function testNotClickable(): void {
|
||||
$this->expectException(Exception::class);
|
||||
$this->drupalGet('/js_interaction_test');
|
||||
$this->assertSession()->elementExists('named', ['link', 'Target link'])->click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert an exception is thrown when the field is never enabled.
|
||||
*/
|
||||
public function testFieldValueNotSettable(): void {
|
||||
$this->expectException(Exception::class);
|
||||
$this->drupalGet('/js_interaction_test');
|
||||
$this->assertSession()->fieldExists('target_field')->setValue('Test');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert no exception is thrown when elements become interactive.
|
||||
*/
|
||||
public function testElementsInteraction(): void {
|
||||
$this->drupalGet('/js_interaction_test');
|
||||
// Remove blocking element after 100 ms.
|
||||
$this->clickLink('Remove Blocker Trigger');
|
||||
$this->clickLink('Target link');
|
||||
|
||||
// Enable field after 100 ms.
|
||||
$this->clickLink('Enable Field Trigger');
|
||||
$this->assertSession()->fieldExists('target_field')->setValue('Test');
|
||||
$this->assertSession()->fieldValueEquals('target_field', 'Test');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Tests;
|
||||
|
||||
use Behat\Mink\Element\NodeElement;
|
||||
use Behat\Mink\Exception\ElementHtmlException;
|
||||
use Drupal\Component\Utility\Timer;
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests for the JSWebAssert class.
|
||||
*
|
||||
* @group javascript
|
||||
*/
|
||||
class JSWebAssertTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* Required modules.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $modules = ['jswebassert_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests that JSWebAssert assertions work correctly.
|
||||
*/
|
||||
public function testJsWebAssert(): void {
|
||||
$this->drupalGet('jswebassert_test_form');
|
||||
|
||||
$session = $this->getSession();
|
||||
$assert_session = $this->assertSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
$assert_session->elementExists('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-pass"]');
|
||||
$page->findButton('Test assertNoElementAfterWait: pass')->press();
|
||||
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-pass"]', 1000);
|
||||
|
||||
$assert_session->elementExists('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]');
|
||||
Timer::start('js_test');
|
||||
$page->findButton('Test assertNoElementAfterWait: fail')->press();
|
||||
$press_time = Timer::read('js_test');
|
||||
try {
|
||||
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]', 500, 'Element exists on page after too short wait.');
|
||||
$wait_time = Timer::read('js_test');
|
||||
$this->fail("Element not exists on page after too short wait. Press time: $press_time ms. Press + Wait time: $wait_time ms. Timestamp: " . microtime(TRUE));
|
||||
}
|
||||
catch (ElementHtmlException $e) {
|
||||
$this->assertSame('Element exists on page after too short wait.', $e->getMessage());
|
||||
}
|
||||
|
||||
$assert_session->assertNoElementAfterWait('css', '[data-drupal-selector="edit-test-assert-no-element-after-wait-fail"]', 2500, 'Element remove after another wait.ss');
|
||||
|
||||
$test_button = $page->findButton('Add button');
|
||||
$test_link = $page->findButton('Add link');
|
||||
$test_field = $page->findButton('Add field');
|
||||
$test_id = $page->findButton('Add ID');
|
||||
$test_wait_on_ajax = $page->findButton('Test assertWaitOnAjaxRequest');
|
||||
$test_wait_on_element_visible = $page->findButton('Test waitForElementVisible');
|
||||
|
||||
// Test the wait...() methods by first checking the fields aren't available
|
||||
// and then are available after the wait method.
|
||||
$result = $page->findButton('Added button');
|
||||
$this->assertEmpty($result);
|
||||
$test_button->click();
|
||||
$result = $assert_session->waitForButton('Added button');
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
|
||||
$result = $page->findLink('Added link');
|
||||
$this->assertEmpty($result);
|
||||
$test_link->click();
|
||||
$result = $assert_session->waitForLink('Added link');
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
|
||||
$result = $page->findField('added_field');
|
||||
$this->assertEmpty($result);
|
||||
$test_field->click();
|
||||
$result = $assert_session->waitForField('added_field');
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
|
||||
$result = $page->findById('jswebassert_test_field_id');
|
||||
$this->assertEmpty($result);
|
||||
$test_id->click();
|
||||
$result = $assert_session->waitForId('jswebassert_test_field_id');
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
|
||||
// Test waitOnAjaxRequest. Verify the element is available after the wait
|
||||
// and the behaviors have run on completing by checking the value.
|
||||
$result = $page->findField('test_assert_wait_on_ajax_input');
|
||||
$this->assertEmpty($result);
|
||||
$test_wait_on_ajax->click();
|
||||
$assert_session->assertWaitOnAjaxRequest();
|
||||
$result = $page->findField('test_assert_wait_on_ajax_input');
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
$this->assertEquals('jswebassert_test', $result->getValue());
|
||||
|
||||
$result = $page->findButton('Added WaitForElementVisible');
|
||||
$this->assertEmpty($result);
|
||||
$test_wait_on_element_visible->click();
|
||||
$result = $assert_session->waitForElementVisible('named', ['button', 'Added WaitForElementVisible']);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertInstanceOf(NodeElement::class, $result);
|
||||
$this->assertEquals(TRUE, $result->isVisible());
|
||||
|
||||
$this->drupalGet('jswebassert_test_page');
|
||||
// Ensure that the javascript has replaced the element 3 times.
|
||||
$this->assertTrue($assert_session->waitForText('New Text!! 3'));
|
||||
$result = $page->find('named', ['id', 'test_text']);
|
||||
$this->assertSame('test_text', $result->getAttribute('id'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\Tests\block\FunctionalJavascript\BlockFilterTest;
|
||||
|
||||
/**
|
||||
* Runs BlockFilterTest in Claro.
|
||||
*
|
||||
* @group block
|
||||
*
|
||||
* @see \Drupal\Tests\block\FunctionalJavascript\BlockFilterTest.
|
||||
*/
|
||||
class ClaroBlockFilterTest extends BlockFilterTest {
|
||||
|
||||
/**
|
||||
* Modules to install.
|
||||
*
|
||||
* Install the shortcut module so that claro.settings has its schema checked.
|
||||
* There's currently no way for Claro to provide a default and have valid
|
||||
* configuration as themes cannot react to a module install.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected static $modules = ['shortcut'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->container->get('theme_installer')->install(['claro']);
|
||||
$this->config('system.theme')->set('default', 'claro')->save();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\entity_test\EntityTestHelper;
|
||||
use Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest;
|
||||
|
||||
/**
|
||||
* Runs EntityDisplayTest in Claro.
|
||||
*
|
||||
* @group claro
|
||||
*
|
||||
* @see \Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest.
|
||||
*/
|
||||
class ClaroEntityDisplayTest extends EntityDisplayTest {
|
||||
|
||||
/**
|
||||
* Modules to install.
|
||||
*
|
||||
* Install the shortcut module so that claro.settings has its schema checked.
|
||||
* There's currently no way for Claro to provide a default and have valid
|
||||
* configuration as themes cannot react to a module install.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected static $modules = ['shortcut'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->container->get('theme_installer')->install(['claro']);
|
||||
$this->config('system.theme')->set('default', 'claro')->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from parent.
|
||||
*
|
||||
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testEntityForm()
|
||||
* with a line changed to reflect row weight toggle being a link instead
|
||||
* of a button.
|
||||
*/
|
||||
public function testEntityForm(): void {
|
||||
$this->drupalGet('entity_test/manage/1/edit');
|
||||
$this->assertSession()->fieldExists('field_test_text[0][value]');
|
||||
|
||||
$this->drupalGet('entity_test/structure/entity_test/form-display');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
|
||||
$this->getSession()->getPage()->pressButton('Show row weights');
|
||||
$this->assertSession()->waitForElementVisible('css', '[name="fields[field_test_text][region]"]');
|
||||
$this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'hidden');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
|
||||
|
||||
$this->submitForm([], 'Save');
|
||||
$this->assertSession()->pageTextContains('Your settings have been saved.');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
|
||||
|
||||
$this->drupalGet('entity_test/manage/1/edit');
|
||||
$this->assertSession()->fieldNotExists('field_test_text[0][value]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from parent.
|
||||
*
|
||||
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testEntityView()
|
||||
* with a line changed to reflect row weight toggle being a link instead
|
||||
* of a button.
|
||||
*/
|
||||
public function testEntityView(): void {
|
||||
$this->drupalGet('entity_test/1');
|
||||
$this->assertSession()->elementNotExists('css', '.field--name-field-test-text');
|
||||
|
||||
$this->drupalGet('entity_test/structure/entity_test/display');
|
||||
$this->assertSession()->elementExists('css', '.region-content-message.region-empty');
|
||||
$this->getSession()->getPage()->pressButton('Show row weights');
|
||||
$this->assertSession()->waitForElementVisible('css', '[name="fields[field_test_text][region]"]');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected());
|
||||
|
||||
$this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'content');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
|
||||
|
||||
$this->submitForm([], 'Save');
|
||||
$this->assertSession()->pageTextContains('Your settings have been saved.');
|
||||
$this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected());
|
||||
|
||||
$this->drupalGet('entity_test/1');
|
||||
$this->assertSession()->elementExists('css', '.field--name-field-test-text');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from parent.
|
||||
*
|
||||
* This is Drupal\Tests\field_ui\FunctionalJavascript\EntityDisplayTest::testExtraFields()
|
||||
* with a line changed to reflect Claro's tabledrag selector.
|
||||
*/
|
||||
public function testExtraFields(): void {
|
||||
EntityTestHelper::createBundle('bundle_with_extra_fields');
|
||||
$this->drupalGet('entity_test/structure/bundle_with_extra_fields/display');
|
||||
$this->assertSession()->waitForElement('css', '.tabledrag-handle');
|
||||
$id = $this->getSession()->getPage()->find('css', '[name="form_build_id"]')->getValue();
|
||||
|
||||
$extra_field_row = $this->getSession()->getPage()->find('css', '#display-extra-field');
|
||||
$disabled_region_row = $this->getSession()->getPage()->find('css', '.region-hidden-title');
|
||||
|
||||
$extra_field_row->find('css', '.js-tabledrag-handle')->dragTo($disabled_region_row);
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
$this->assertSession()
|
||||
->waitForElement('css', "[name='form_build_id']:not([value='$id'])");
|
||||
|
||||
$this->submitForm([], 'Save');
|
||||
$this->assertSession()->pageTextContains('Your settings have been saved.');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\Tests\menu_ui\FunctionalJavascript\MenuUiJavascriptTest;
|
||||
|
||||
/**
|
||||
* Runs MenuUiJavascriptTest in Claro.
|
||||
*
|
||||
* @group claro
|
||||
*
|
||||
* @see \Drupal\Tests\menu_ui\FunctionalJavascript\MenuUiJavascriptTest;
|
||||
*/
|
||||
class ClaroMenuUiJavascriptTest extends MenuUiJavascriptTest {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'shortcut',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->container->get('theme_installer')->install(['claro']);
|
||||
$this->config('system.theme')->set('default', 'claro')->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Intentionally empty method.
|
||||
*
|
||||
* Contextual links do not work in admin themes, so this is empty to prevent
|
||||
* this test running in the parent class.
|
||||
*/
|
||||
public function testBlockContextualLinks(): void {
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\Tests\media_library\FunctionalJavascript\MediaLibraryTestBase;
|
||||
use Drupal\Tests\TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests that buttons in modals are not in their button pane.
|
||||
*
|
||||
* @group claro
|
||||
*/
|
||||
class ClaroModalDisplayTest extends MediaLibraryTestBase {
|
||||
|
||||
use TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'claro';
|
||||
|
||||
/**
|
||||
* Tests the position f "add another" button in dialogs.
|
||||
*/
|
||||
public function testModalAddAnother(): void {
|
||||
|
||||
// Add unlimited field to the media type four.
|
||||
$unlimited_field_storage = FieldStorageConfig::create([
|
||||
'entity_type' => 'media',
|
||||
'field_name' => 'unlimited',
|
||||
'type' => 'string',
|
||||
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
|
||||
]);
|
||||
$unlimited_field_storage->save();
|
||||
$unlimited_field = FieldConfig::create([
|
||||
'field_storage' => $unlimited_field_storage,
|
||||
'bundle' => 'type_four',
|
||||
'label' => 'Unlimited',
|
||||
]);
|
||||
$unlimited_field->save();
|
||||
|
||||
/** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
|
||||
$display_repository = \Drupal::service('entity_display.repository');
|
||||
|
||||
$display_repository->getFormDisplay('media', 'type_four', 'media_library')
|
||||
->setComponent('unlimited', [
|
||||
'type' => 'string_textfield',
|
||||
])
|
||||
->save();
|
||||
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
foreach ($this->getTestFiles('image') as $image) {
|
||||
$extension = pathinfo($image->filename, PATHINFO_EXTENSION);
|
||||
if ($extension === 'jpg') {
|
||||
$jpg_image = $image;
|
||||
}
|
||||
}
|
||||
if (!isset($jpg_image)) {
|
||||
$this->fail('Expected test files not present.');
|
||||
}
|
||||
|
||||
// Create a user that can create media for all media types.
|
||||
$user = $this->drupalCreateUser([
|
||||
'access administration pages',
|
||||
'access content',
|
||||
'create basic_page content',
|
||||
'create media',
|
||||
'view media',
|
||||
]);
|
||||
$this->drupalLogin($user);
|
||||
|
||||
// Visit a node create page.
|
||||
$this->drupalGet('node/add/basic_page');
|
||||
|
||||
// Add to the twin media field.
|
||||
$this->openMediaLibraryForField('field_twin_media');
|
||||
$this->switchToMediaType('Four');
|
||||
|
||||
// A file needs to be added for the unlimited field to appear in the form.
|
||||
$this->addMediaFileToField('Add files', $this->container->get('file_system')->realpath($jpg_image->uri));
|
||||
|
||||
// Wait for the file upload to be completed.
|
||||
// Copied from \Drupal\Tests\media_library\FunctionalJavascript\MediaLibraryTestBase::assertMediaAdded.
|
||||
$selector = '.js-media-library-add-form-added-media';
|
||||
$this->assertJsCondition('jQuery("' . $selector . '").is(":focus")');
|
||||
|
||||
// Assert that the 'add another item' button is not in the dialog footer.
|
||||
$assert_session->elementNotExists('css', '.ui-dialog-buttonset .field-add-more-submit');
|
||||
$assert_session->elementExists('css', '.ui-dialog-content .field-add-more-submit');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\Tests\user\FunctionalJavascript\PasswordConfirmWidgetTest;
|
||||
|
||||
/**
|
||||
* Tests the password confirm widget with Claro theme.
|
||||
*
|
||||
* @group claro
|
||||
*/
|
||||
class ClaroPasswordConfirmWidgetTest extends PasswordConfirmWidgetTest {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'claro';
|
||||
|
||||
/**
|
||||
* Tests that password match message is invisible when widget is initialized.
|
||||
*/
|
||||
public function testPasswordConfirmMessage(): void {
|
||||
$this->drupalGet($this->testUser->toUrl('edit-form'));
|
||||
$password_confirm_widget_selector = '.js-form-type-password-confirm.js-form-item-pass';
|
||||
$password_confirm_selector = '.js-form-item-pass-pass2';
|
||||
$password_confirm_widget = $this->assert->elementExists('css', $password_confirm_widget_selector);
|
||||
$password_confirm_item = $password_confirm_widget->find('css', $password_confirm_selector);
|
||||
|
||||
// Password match message.
|
||||
$this->assertTrue($password_confirm_item->has('css', 'input.js-password-confirm + [data-drupal-selector="password-confirm-message"]'));
|
||||
$this->assertFalse($password_confirm_item->find('css', 'input.js-password-confirm + [data-drupal-selector="password-confirm-message"]')->isVisible());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function testFillConfirmOnly(): void {
|
||||
// This test is not applicable to Claro because confirm field is hidden
|
||||
// until the password has been filled in the main field.
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\TableDrag\TableDragTest;
|
||||
|
||||
/**
|
||||
* Tests draggable tables with Claro theme.
|
||||
*
|
||||
* @group claro
|
||||
*
|
||||
* @see \Drupal\FunctionalJavascriptTests\TableDrag\TableDragTest
|
||||
*/
|
||||
class ClaroTableDragTest extends TableDragTest {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'claro';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $indentationXpathSelector = 'child::td[1]/div[contains(concat(" ", normalize-space(@class), " "), " js-tabledrag-cell-content ")]/div[contains(concat(" ", normalize-space(@class), " "), " js-indentation ")]';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $tabledragChangedXpathSelector = 'child::td[1]/div[contains(concat(" ", normalize-space(@class), " "), " js-tabledrag-cell-content ")]/abbr[contains(concat(" ", normalize-space(@class), " "), " tabledrag-changed ")]';
|
||||
|
||||
/**
|
||||
* Ensures that there are no duplicate tabledrag handles.
|
||||
*/
|
||||
public function testNoDuplicates(): void {
|
||||
$this->drupalGet('tabledrag_test_nested');
|
||||
$this->assertCount(1, $this->findRowById(1)->findAll('css', '.tabledrag-handle'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
|
||||
use Drupal\Tests\node\Traits\NodeCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests Claro's Views Bulk Operations form.
|
||||
*
|
||||
* @group claro
|
||||
*/
|
||||
class ClaroViewsBulkOperationsTest extends WebDriverTestBase {
|
||||
use ContentTypeCreationTrait;
|
||||
use NodeCreationTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['node', 'views'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'claro';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Create a Content type and two test nodes.
|
||||
$this->createContentType(['type' => 'page']);
|
||||
$this->createNode(['title' => 'Page One']);
|
||||
$this->createNode(['title' => 'Page Two']);
|
||||
|
||||
// Create a user privileged enough to use exposed filters and view content.
|
||||
$user = $this->drupalCreateUser([
|
||||
'administer site configuration',
|
||||
'access content',
|
||||
'access content overview',
|
||||
'edit any page content',
|
||||
]);
|
||||
$this->drupalLogin($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dynamic Bulk Operations form.
|
||||
*/
|
||||
public function testBulkOperationsUi(): void {
|
||||
$this->drupalGet('admin/content');
|
||||
|
||||
$page = $this->getSession()->getPage();
|
||||
$assert_session = $this->assertSession();
|
||||
|
||||
$no_items_selected = 'No items selected';
|
||||
$one_item_selected = '1 item selected';
|
||||
$two_items_selected = '2 items selected';
|
||||
$vbo_available_message = 'Bulk actions are now available';
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
|
||||
$select_all = $page->find('css', '.select-all > input');
|
||||
|
||||
$page->checkField('node_bulk_form[0]');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
|
||||
|
||||
// When the bulk operations controls are first activated, this should be
|
||||
// relayed to screen readers.
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
|
||||
$this->assertFalse($select_all->isChecked());
|
||||
|
||||
$page->checkField('node_bulk_form[1]');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
|
||||
$assert_session->pageTextNotContains($vbo_available_message);
|
||||
$this->assertTrue($select_all->isChecked());
|
||||
|
||||
$page->uncheckField('node_bulk_form[0]');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$one_item_selected\")"));
|
||||
$assert_session->pageTextNotContains($vbo_available_message);
|
||||
$this->assertFalse($select_all->isChecked());
|
||||
|
||||
$page->uncheckField('node_bulk_form[1]');
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
|
||||
$assert_session->pageTextNotContains($vbo_available_message);
|
||||
$this->assertFalse($select_all->isChecked());
|
||||
|
||||
$select_all->check();
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
|
||||
|
||||
$select_all->uncheck();
|
||||
$this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
|
||||
$this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
|
||||
$assert_session->pageTextNotContains($vbo_available_message);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Runs tests on Views UI using Claro.
|
||||
*
|
||||
* @group claro
|
||||
*/
|
||||
class ClaroViewsUiTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['views_ui'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'claro';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Disable automatic live preview to make the sequence of calls clearer.
|
||||
$this->config('views.settings')->set('ui.always_live_preview', FALSE)->save();
|
||||
|
||||
// Create the test user and log in.
|
||||
$admin_user = $this->drupalCreateUser([
|
||||
'administer views',
|
||||
'access administration pages',
|
||||
'view the administration theme',
|
||||
]);
|
||||
$this->drupalLogin($admin_user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Views UI display menu tabs CSS classes.
|
||||
*
|
||||
* Ensures that the CSS classes added to display menu tabs are preserved when
|
||||
* Views UI is updated with AJAX.
|
||||
*/
|
||||
public function testViewsUiTabsCssClasses(): void {
|
||||
$this->drupalGet('admin/structure/views/view/who_s_online');
|
||||
$assert_session = $this->assertSession();
|
||||
$assert_session->elementExists('css', '#views-display-menu-tabs.views-tabs.views-tabs--secondary');
|
||||
// Click on the Display name and wait for the Views UI dialog.
|
||||
$assert_session->elementExists('css', '#edit-display-settings-top .views-display-setting a')->click();
|
||||
$this->assertNotNull($this->assertSession()->waitForElement('css', '.js-views-ui-dialog'));
|
||||
// Click the Apply button of the dialog.
|
||||
$assert_session->elementExists('css', '.js-views-ui-dialog .ui-dialog-buttonpane')->findButton('Apply')->press();
|
||||
// Wait for AJAX to finish.
|
||||
$assert_session->assertWaitOnAjaxRequest();
|
||||
|
||||
// Check that the display menu tabs list still has the expected CSS classes.
|
||||
$assert_session->elementExists('css', '#views-display-menu-tabs.views-tabs.views-tabs--secondary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Views UI dropbutton CSS classes.
|
||||
*
|
||||
* Ensures that the CSS classes added to the Views UI extra actions dropbutton
|
||||
* in .views-display-top are preserved when Views UI is refreshed with AJAX.
|
||||
*/
|
||||
public function testViewsUiDropButtonCssClasses(): void {
|
||||
$this->drupalGet('admin/structure/views/view/who_s_online');
|
||||
$assert_session = $this->assertSession();
|
||||
$extra_actions_dropbutton_list = $assert_session->elementExists('css', '#views-display-extra-actions.dropbutton--small');
|
||||
$list_item_selectors = ['li:first-child', 'li:last-child'];
|
||||
// Test list item CSS classes.
|
||||
foreach ($list_item_selectors as $list_item_selector) {
|
||||
$this->assertNotNull($extra_actions_dropbutton_list->find('css', "$list_item_selector.dropbutton__item"));
|
||||
}
|
||||
|
||||
// Click on the Display name and wait for the Views UI dialog.
|
||||
$assert_session->elementExists('css', '#edit-display-settings-top .views-display-setting a')->click();
|
||||
$this->assertNotNull($this->assertSession()->waitForElement('css', '.js-views-ui-dialog'));
|
||||
// Click the Apply button of the dialog.
|
||||
$assert_session->elementExists('css', '.js-views-ui-dialog .ui-dialog-buttonpane')->findButton('Apply')->press();
|
||||
// Wait for AJAX to finish.
|
||||
$this->assertSession()->assertWaitOnAjaxRequest();
|
||||
|
||||
// Check that the drop button list still has the expected CSS classes.
|
||||
$this->assertTrue($extra_actions_dropbutton_list->hasClass('dropbutton--small'));
|
||||
// Check list item CSS classes.
|
||||
foreach ($list_item_selectors as $list_item_selector) {
|
||||
$this->assertNotNull($extra_actions_dropbutton_list->find('css', "$list_item_selector.dropbutton__item"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
|
||||
|
||||
/**
|
||||
* Tests usage of localStorage.
|
||||
*
|
||||
* @group olivero
|
||||
*/
|
||||
final class OliveroAvoidStorageUsingTest extends WebDriverTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['block', 'node'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'olivero';
|
||||
|
||||
/**
|
||||
* Tests use of localStorage.
|
||||
*/
|
||||
public function testStorageUsing(): void {
|
||||
$this->drupalGet('<front>');
|
||||
// Check if initial no storage item is written.
|
||||
$this->assertJsCondition("localStorage.getItem('Drupal.olivero.stickyHeaderState') === null", 10000, 'Written not strictly necessary Drupal.olivero.stickyHeaderState to localStorage without consent.');
|
||||
|
||||
// Resize and scroll to show stickyHeaderToggleButton.
|
||||
$session = $this->getSession();
|
||||
$session->resizeWindow(1280, 1024);
|
||||
$session->executeScript('window.scrollTo(0, 500);');
|
||||
|
||||
// Click stickyHeaderToggleButton.
|
||||
$this->getSession()->getPage()->find('css', '.sticky-header-toggle')->click();
|
||||
|
||||
// Test if localStorage is set now.
|
||||
$this->assertJsCondition("localStorage.getItem('Drupal.olivero.stickyHeaderState') !== null");
|
||||
|
||||
// Click stickyHeaderToggleButton again.
|
||||
$this->getSession()->getPage()->find('css', '.sticky-header-toggle')->click();
|
||||
|
||||
// Storage item should be removed now.
|
||||
$this->assertJsCondition("localStorage.getItem('Drupal.olivero.stickyHeaderState') === null", 10000, 'Storage item Drupal.olivero.stickyHeaderState should be removed.');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests\Theme;
|
||||
|
||||
use Drupal\FunctionalJavascriptTests\Core\JsMessageTest;
|
||||
use Drupal\js_message_test\Controller\JSMessageTestController;
|
||||
|
||||
/**
|
||||
* Runs OliveroMessagesTest in Olivero.
|
||||
*
|
||||
* @group olivero
|
||||
*
|
||||
* @see \Drupal\FunctionalJavascriptTests\Core\JsMessageTest.
|
||||
*/
|
||||
class OliveroMessagesTest extends JsMessageTest {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'js_message_test',
|
||||
'system',
|
||||
'block',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Enable the theme.
|
||||
\Drupal::service('theme_installer')->install(['olivero']);
|
||||
$theme_config = \Drupal::configFactory()->getEditable('system.theme');
|
||||
$theme_config->set('default', 'olivero');
|
||||
$theme_config->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests data-drupal-selector="messages" exists.
|
||||
*/
|
||||
public function testDataDrupalSelectors(): void {
|
||||
$web_assert = $this->assertSession();
|
||||
$this->drupalGet('js_message_test_link');
|
||||
|
||||
foreach (JSMessageTestController::getMessagesSelectors() as $messagesSelector) {
|
||||
$web_assert->elementExists('css', $messagesSelector);
|
||||
foreach (JSMessageTestController::getTypes() as $type) {
|
||||
$this->click('[id="add-' . $messagesSelector . '-' . $type . '"]');
|
||||
$selector = '[data-drupal-selector="messages"]';
|
||||
$msg_element = $web_assert->waitForElementVisible('css', $selector);
|
||||
$this->assertNotEmpty($msg_element, "Message element visible: $selector");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use WebDriver\Service\CurlService;
|
||||
use WebDriver\Exception\CurlExec;
|
||||
use WebDriver\Exception as WebDriverException;
|
||||
|
||||
// cspell:ignore curle curlopt customrequest failonerror postfields
|
||||
// cspell:ignore returntransfer
|
||||
|
||||
@trigger_error('The \Drupal\FunctionalJavascriptTests\WebDriverCurlService class is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3462152', E_USER_DEPRECATED);
|
||||
|
||||
/**
|
||||
* Provides a curl service to interact with Selenium driver.
|
||||
*
|
||||
* Extends WebDriver\Service\CurlService to solve problem with race conditions,
|
||||
* when multiple processes requests.
|
||||
*
|
||||
* @deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is
|
||||
* no replacement, use the base class instead.
|
||||
*
|
||||
* @see https://www.drupal.org/node/3462152
|
||||
*/
|
||||
class WebDriverCurlService extends CurlService {
|
||||
|
||||
/**
|
||||
* Flag that indicates if retries are enabled.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $retry = TRUE;
|
||||
|
||||
/**
|
||||
* Enables retries.
|
||||
*
|
||||
* This is useful if the caller is implementing it's own waiting process.
|
||||
*/
|
||||
public static function enableRetry() {
|
||||
static::$retry = TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables retries.
|
||||
*
|
||||
* This is useful if the caller is implementing it's own waiting process.
|
||||
*/
|
||||
public static function disableRetry() {
|
||||
static::$retry = FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function execute($requestMethod, $url, $parameters = NULL, $extraOptions = []) {
|
||||
$extraOptions += [
|
||||
CURLOPT_FAILONERROR => TRUE,
|
||||
];
|
||||
$retries = 0;
|
||||
$max_retries = static::$retry ? 10 : 1;
|
||||
while ($retries < $max_retries) {
|
||||
try {
|
||||
$customHeaders = [
|
||||
'Content-Type: application/json;charset=UTF-8',
|
||||
'Accept: application/json;charset=UTF-8',
|
||||
];
|
||||
|
||||
$curl = curl_init($url);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
|
||||
|
||||
switch ($requestMethod) {
|
||||
case 'GET':
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($parameters && is_array($parameters)) {
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($parameters));
|
||||
}
|
||||
else {
|
||||
$customHeaders[] = 'Content-Length: 0';
|
||||
|
||||
// Suppress "Transfer-Encoding: chunked" header automatically
|
||||
// added by cURL that causes a 400 bad request (bad
|
||||
// content-length).
|
||||
$customHeaders[] = 'Transfer-Encoding:';
|
||||
}
|
||||
|
||||
// Suppress "Expect: 100-continue" header automatically added by
|
||||
// cURL that causes a 1 second delay if the remote server does not
|
||||
// support Expect.
|
||||
$customHeaders[] = 'Expect:';
|
||||
|
||||
curl_setopt($curl, CURLOPT_POST, TRUE);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if ($parameters && is_array($parameters)) {
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($parameters));
|
||||
}
|
||||
else {
|
||||
$customHeaders[] = 'Content-Length: 0';
|
||||
|
||||
// Suppress "Transfer-Encoding: chunked" header automatically
|
||||
// added by cURL that causes a 400 bad request (bad
|
||||
// content-length).
|
||||
$customHeaders[] = 'Transfer-Encoding:';
|
||||
}
|
||||
|
||||
// Suppress "Expect: 100-continue" header automatically added by
|
||||
// cURL that causes a 1 second delay if the remote server does not
|
||||
// support Expect.
|
||||
$customHeaders[] = 'Expect:';
|
||||
|
||||
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($extraOptions as $option => $value) {
|
||||
curl_setopt($curl, $option, $value);
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $customHeaders);
|
||||
|
||||
$result = curl_exec($curl);
|
||||
$rawResult = NULL;
|
||||
if ($result !== FALSE) {
|
||||
$rawResult = trim($result);
|
||||
}
|
||||
|
||||
$info = curl_getinfo($curl);
|
||||
$info['request_method'] = $requestMethod;
|
||||
|
||||
if (array_key_exists(CURLOPT_FAILONERROR, $extraOptions) && $extraOptions[CURLOPT_FAILONERROR] && CURLE_GOT_NOTHING !== curl_errno($curl) && $error = curl_error($curl)) {
|
||||
$curl = NULL;
|
||||
|
||||
throw WebDriverException::factory(WebDriverException::CURL_EXEC, sprintf("Curl error thrown for http %s to %s%s\n\n%s", $requestMethod, $url, $parameters && is_array($parameters) ? ' with params: ' . json_encode($parameters) : '', $error));
|
||||
}
|
||||
|
||||
$curl = NULL;
|
||||
|
||||
$result = json_decode($rawResult, TRUE);
|
||||
if (isset($result['status']) && $result['status'] === WebDriverException::STALE_ELEMENT_REFERENCE) {
|
||||
usleep(100000);
|
||||
$retries++;
|
||||
continue;
|
||||
}
|
||||
return [$rawResult, $info];
|
||||
}
|
||||
catch (CurlExec) {
|
||||
$retries++;
|
||||
}
|
||||
}
|
||||
if (empty($error)) {
|
||||
$error = "Retries: $retries and last result:\n" . ($rawResult ?? '');
|
||||
}
|
||||
throw WebDriverException::factory(WebDriverException::CURL_EXEC, sprintf("Curl error thrown for http %s to %s%s\n\n%s", $requestMethod, $url, $parameters && is_array($parameters) ? ' with params: ' . json_encode($parameters) : '', $error));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
use Drupal\Component\Utility\UrlHelper;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Runs a browser test using a driver that supports JavaScript.
|
||||
*
|
||||
* Module tests extending WebDriverTestBase must exist in the
|
||||
* Drupal\Tests\your_module\FunctionalJavascript namespace and live in the
|
||||
* modules/your_module/tests/src/FunctionalJavascript directory.
|
||||
*
|
||||
* Tests for core/lib/Drupal classes extending WebDriverTestBase must exist in
|
||||
* the \Drupal\FunctionalJavascriptTests\Core namespace and live in the
|
||||
* core/tests/Drupal/FunctionalJavascriptTests directory.
|
||||
*
|
||||
* Base class for testing browser interaction implemented in JavaScript.
|
||||
*
|
||||
* @ingroup testing
|
||||
*/
|
||||
abstract class WebDriverTestBase extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* Determines if a test should fail on JavaScript console errors.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $failOnJavascriptConsoleErrors = TRUE;
|
||||
|
||||
/**
|
||||
* Disables CSS animations in tests for more reliable testing.
|
||||
*
|
||||
* CSS animations are disabled by installing the css_disable_transitions_test
|
||||
* module. Set to FALSE to test CSS animations.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $disableCssAnimations = TRUE;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $minkDefaultDriverClass = DrupalSelenium2Driver::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function initMink() {
|
||||
if (!is_a($this->minkDefaultDriverClass, DrupalSelenium2Driver::class, TRUE)) {
|
||||
throw new \UnexpectedValueException(sprintf("%s has to be an instance of %s", $this->minkDefaultDriverClass, DrupalSelenium2Driver::class));
|
||||
}
|
||||
$this->minkDefaultDriverArgs = ['chrome', ['goog:chromeOptions' => ['w3c' => FALSE]], 'http://localhost:4444'];
|
||||
|
||||
try {
|
||||
return parent::initMink();
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
// If it's not possible to get a mink connection ensure that mink's own
|
||||
// destructor is called immediately, to avoid it being called in
|
||||
// ::tearDown(), then rethrow the exception.
|
||||
$this->mink = NULL;
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function installModulesFromClassProperty(ContainerInterface $container) {
|
||||
self::$modules = [
|
||||
'js_testing_ajax_request_test',
|
||||
'js_testing_log_test',
|
||||
];
|
||||
if ($this->disableCssAnimations) {
|
||||
self::$modules[] = 'css_disable_transitions_test';
|
||||
}
|
||||
parent::installModulesFromClassProperty($container);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function initFrontPage() {
|
||||
parent::initFrontPage();
|
||||
// Set a standard window size so that all javascript tests start with the
|
||||
// same viewport.
|
||||
$this->getSession()->resizeWindow(1024, 768);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void {
|
||||
if ($this->mink) {
|
||||
// Wait for all requests to finish. It is possible that an AJAX request is
|
||||
// still on-going.
|
||||
$result = $this->getSession()->wait(5000, 'window.drupalActiveXhrCount === 0 || typeof window.drupalActiveXhrCount === "undefined"');
|
||||
if (!$result) {
|
||||
// If the wait is unsuccessful, there may still be an AJAX request in
|
||||
// progress. If we tear down now, then this AJAX request may fail with
|
||||
// missing database tables, because tear down will have removed them.
|
||||
// Rather than allow it to fail, throw an explicit exception now
|
||||
// explaining what the problem is.
|
||||
throw new \RuntimeException('Unfinished AJAX requests while tearing down a test');
|
||||
}
|
||||
|
||||
$warnings = $this->getSession()->evaluateScript("JSON.parse(sessionStorage.getItem('js_testing_log_test.warnings') || JSON.stringify([]))");
|
||||
foreach ($warnings as $warning) {
|
||||
if (str_starts_with($warning, '[Deprecation]')) {
|
||||
// phpcs:ignore Drupal.Semantics.FunctionTriggerError
|
||||
@trigger_error('Javascript Deprecation:' . substr($warning, 13), E_USER_DEPRECATED);
|
||||
}
|
||||
}
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a test failure if a JavaScript error was encountered.
|
||||
*
|
||||
* @throws \PHPUnit\Framework\AssertionFailedError
|
||||
*
|
||||
* @postCondition
|
||||
*/
|
||||
protected function failOnJavaScriptErrors(): void {
|
||||
if ($this->failOnJavascriptConsoleErrors) {
|
||||
$errors = $this->getSession()->evaluateScript("JSON.parse(sessionStorage.getItem('js_testing_log_test.errors') || JSON.stringify([]))");
|
||||
if (!empty($errors)) {
|
||||
$this->fail(implode("\n", $errors));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getMinkDriverArgs() {
|
||||
if ($this->minkDefaultDriverClass === DrupalSelenium2Driver::class) {
|
||||
$json = getenv('MINK_DRIVER_ARGS_WEBDRIVER') ?: parent::getMinkDriverArgs();
|
||||
if (!($json === FALSE || $json === '')) {
|
||||
$args = json_decode($json, TRUE);
|
||||
if (isset($args[0]) && $args[0] === 'chrome' && !isset($args[1]['goog:chromeOptions']['w3c'])) {
|
||||
// @todo https://www.drupal.org/project/drupal/issues/3421202
|
||||
// Deprecate defaulting behavior and require w3c to be set.
|
||||
$args[1]['goog:chromeOptions']['w3c'] = FALSE;
|
||||
}
|
||||
$json = json_encode($args);
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
return parent::getMinkDriverArgs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the given time or until the given JS condition becomes TRUE.
|
||||
*
|
||||
* @param string $condition
|
||||
* JS condition to wait until it becomes TRUE.
|
||||
* @param int $timeout
|
||||
* (Optional) Timeout in milliseconds, defaults to 10000.
|
||||
* @param string $message
|
||||
* (optional) A message to display with the assertion. If left blank, a
|
||||
* default message will be displayed.
|
||||
*
|
||||
* @throws \PHPUnit\Framework\AssertionFailedError
|
||||
*
|
||||
* @see \Behat\Mink\Driver\DriverInterface::evaluateScript()
|
||||
*/
|
||||
protected function assertJsCondition($condition, $timeout = 10000, $message = '') {
|
||||
$message = $message ?: "JavaScript condition met:\n" . $condition;
|
||||
$result = $this->getSession()->getDriver()->wait($timeout, $condition);
|
||||
$this->assertTrue($result, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a screenshot.
|
||||
*
|
||||
* @param string $filename
|
||||
* The file name of the resulting screenshot including a writable path. For
|
||||
* example, /tmp/test_screenshot.jpg.
|
||||
* @param bool $set_background_color
|
||||
* (optional) By default this method will set the background color to white.
|
||||
* Set to FALSE to override this behavior.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\UnsupportedDriverActionException
|
||||
* When operation not supported by the driver.
|
||||
* @throws \Behat\Mink\Exception\DriverException
|
||||
* When the operation cannot be done.
|
||||
*/
|
||||
protected function createScreenshot($filename, $set_background_color = TRUE) {
|
||||
$session = $this->getSession();
|
||||
if ($set_background_color) {
|
||||
$session->executeScript("document.body.style.backgroundColor = 'white';");
|
||||
}
|
||||
$image = $session->getScreenshot();
|
||||
file_put_contents($filename, $image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns WebAssert object.
|
||||
*
|
||||
* @param string $name
|
||||
* (optional) Name of the session. Defaults to the active session.
|
||||
*
|
||||
* @return \Drupal\FunctionalJavascriptTests\WebDriverWebAssert
|
||||
* A new web-assert option for asserting the presence of elements with.
|
||||
*/
|
||||
public function assertSession($name = NULL) {
|
||||
return new WebDriverWebAssert($this->getSession($name), $this->baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current Drupal javascript settings and parses into an array.
|
||||
*
|
||||
* Unlike BrowserTestBase::getDrupalSettings(), this implementation reads the
|
||||
* current values of drupalSettings, capturing all changes made via javascript
|
||||
* after the page was loaded.
|
||||
*
|
||||
* @return array
|
||||
* The Drupal javascript settings array.
|
||||
*
|
||||
* @see \Drupal\Tests\BrowserTestBase::getDrupalSettings()
|
||||
*/
|
||||
protected function getDrupalSettings() {
|
||||
$script = <<<EndOfScript
|
||||
(function () {
|
||||
if (typeof drupalSettings !== 'undefined') {
|
||||
return drupalSettings;
|
||||
}
|
||||
})();
|
||||
EndOfScript;
|
||||
$settings = $this->getSession()->evaluateScript($script) ?: [];
|
||||
if (isset($settings['ajaxPageState'])) {
|
||||
$settings['ajaxPageState']['libraries'] = UrlHelper::uncompressQueryParameter($settings['ajaxPageState']['libraries']);
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getHtmlOutputHeaders() {
|
||||
// The webdriver API does not support fetching headers.
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalJavascriptTests;
|
||||
|
||||
/**
|
||||
* Defines a JSWebAssert with no support for status code and header assertions.
|
||||
*/
|
||||
class WebDriverWebAssert extends JSWebAssert {
|
||||
|
||||
/**
|
||||
* The use of statusCodeEquals() is not available.
|
||||
*
|
||||
* @param int $code
|
||||
* The status code.
|
||||
*/
|
||||
public function statusCodeEquals($code) {
|
||||
@trigger_error('WebDriverWebAssert::statusCodeEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::statusCodeEquals($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of statusCodeNotEquals() is not available.
|
||||
*
|
||||
* @param int $code
|
||||
* The status code.
|
||||
*/
|
||||
public function statusCodeNotEquals($code) {
|
||||
@trigger_error('WebDriverWebAssert::statusCodeNotEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::statusCodeNotEquals($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderEquals() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $value
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderEquals($name, $value) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderEquals($name, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderNotEquals() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $value
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderNotEquals($name, $value) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderNotEquals() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderNotEquals($name, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderContains() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $value
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderContains($name, $value) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderContains() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderContains($name, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderNotContains() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $value
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderNotContains($name, $value) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderNotContains() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderNotContains($name, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderMatches() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $regex
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderMatches($name, $regex) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderMatches() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderMatches($name, $regex);
|
||||
}
|
||||
|
||||
/**
|
||||
* The use of responseHeaderNotMatches() is not available.
|
||||
*
|
||||
* @param string $name
|
||||
* The name of the header.
|
||||
* @param string $regex
|
||||
* The value to check the header against.
|
||||
*/
|
||||
public function responseHeaderNotMatches($name, $regex) {
|
||||
@trigger_error('WebDriverWebAssert::responseHeaderNotMatches() is deprecated in drupal:8.4.0 and is removed in drupal:12.0.0. See https://www.drupal.org/node/2857562', E_USER_DEPRECATED);
|
||||
parent::responseHeaderNotMatches($name, $regex);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Asset;
|
||||
|
||||
use Drupal\Component\Utility\UrlHelper;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
// cspell:ignore abcdefghijklmnop
|
||||
|
||||
/**
|
||||
* Tests asset aggregation.
|
||||
*
|
||||
* @group asset
|
||||
*/
|
||||
class AssetOptimizationTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* The file assets path settings value.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fileAssetsPath;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['system'];
|
||||
|
||||
/**
|
||||
* Tests that asset aggregates are rendered and created on disk.
|
||||
*/
|
||||
public function testAssetAggregation(): void {
|
||||
// Test aggregation with a custom file_assets_path.
|
||||
$this->fileAssetsPath = $this->publicFilesDirectory . '/test-assets';
|
||||
$settings['settings']['file_assets_path'] = (object) [
|
||||
'value' => $this->fileAssetsPath,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->doTestAggregation($settings);
|
||||
|
||||
// Test aggregation with no configured file_assets_path or file_public_path,
|
||||
// since tests run in a multisite, this tests multisite installs where
|
||||
// settings.php is the default.
|
||||
$this->fileAssetsPath = $this->publicFilesDirectory;
|
||||
$settings['settings']['file_public_path'] = (object) [
|
||||
'value' => NULL,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$settings['settings']['file_assets_path'] = (object) [
|
||||
'value' => NULL,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->doTestAggregation($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user and requests a page.
|
||||
*/
|
||||
protected function requestPage(): void {
|
||||
$user = $this->createUser();
|
||||
$this->drupalLogin($user);
|
||||
$this->drupalGet('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to test aggregate file URLs.
|
||||
*
|
||||
* @param array $settings
|
||||
* A settings array to pass to ::writeSettings()
|
||||
*/
|
||||
protected function doTestAggregation(array $settings): void {
|
||||
$this->writeSettings($settings);
|
||||
$this->rebuildAll();
|
||||
$this->config('system.performance')->set('css', [
|
||||
'preprocess' => TRUE,
|
||||
'gzip' => TRUE,
|
||||
])->save();
|
||||
$this->config('system.performance')->set('js', [
|
||||
'preprocess' => TRUE,
|
||||
'gzip' => TRUE,
|
||||
])->save();
|
||||
$this->requestPage();
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
// Collect all the URLs for all the script and styles prior to making any
|
||||
// more requests.
|
||||
$style_elements = $page->findAll('xpath', '//link[@href and @rel="stylesheet"]');
|
||||
$script_elements = $page->findAll('xpath', '//script[@src]');
|
||||
$style_urls = [];
|
||||
foreach ($style_elements as $element) {
|
||||
$style_urls[] = $element->getAttribute('href');
|
||||
}
|
||||
$script_urls = [];
|
||||
foreach ($script_elements as $element) {
|
||||
$script_urls[] = $element->getAttribute('src');
|
||||
}
|
||||
foreach ($style_urls as $url) {
|
||||
$this->assertAggregate($url, TRUE, 'text/css');
|
||||
// Once the file has been requested once, it's on disk. It is possible for
|
||||
// a second request to hit the controller, and then find that another
|
||||
// request has created the file already. Actually simulating this race
|
||||
// condition is not really possible since it relies on timing. However, by
|
||||
// changing the case of the part of the URL that is handled by Drupal
|
||||
// routing, we can force the request to be served by Drupal.
|
||||
$this->assertAggregate(str_replace($this->fileAssetsPath, strtoupper($this->fileAssetsPath), $url), TRUE, 'text/css');
|
||||
$this->assertAggregate($url, FALSE, 'text/css');
|
||||
$this->assertInvalidAggregates($url);
|
||||
}
|
||||
|
||||
foreach ($script_urls as $url) {
|
||||
$this->assertAggregate($url);
|
||||
$this->assertAggregate($url, FALSE);
|
||||
$this->assertInvalidAggregates($url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the aggregate header.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
* @param bool $from_php
|
||||
* (optional) Is the result from PHP or disk? Defaults to TRUE (PHP).
|
||||
* @param string|null $content_type
|
||||
* The expected content type, or NULL to skip checking.
|
||||
*/
|
||||
protected function assertAggregate(string $url, bool $from_php = TRUE, ?string $content_type = NULL): void {
|
||||
$url = $this->getAbsoluteUrl($url);
|
||||
if (!stripos($url, $this->fileAssetsPath) !== FALSE) {
|
||||
return;
|
||||
}
|
||||
$session = $this->getSession();
|
||||
$session->visit($url);
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$headers = $session->getResponseHeaders();
|
||||
if (isset($content_type)) {
|
||||
$this->assertStringContainsString($content_type, $headers['Content-Type'][0]);
|
||||
}
|
||||
if ($from_php) {
|
||||
$this->assertStringContainsString('no-store', $headers['Cache-Control'][0]);
|
||||
$this->assertArrayHasKey('X-Generator', $headers);
|
||||
}
|
||||
else {
|
||||
$this->assertArrayNotHasKey('X-Generator', $headers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the aggregate when it is invalid.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @throws \Behat\Mink\Exception\ExpectationException
|
||||
*/
|
||||
protected function assertInvalidAggregates(string $url): void {
|
||||
$url = $this->getAbsoluteUrl($url);
|
||||
// Not every script or style on a page is aggregated.
|
||||
if (!str_contains($url, $this->fileAssetsPath)) {
|
||||
return;
|
||||
}
|
||||
$session = $this->getSession();
|
||||
$session->visit($this->replaceGroupDelta($url));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
$session->visit($this->omitTheme($url));
|
||||
$this->assertSession()->statusCodeEquals(400);
|
||||
|
||||
$session->visit($this->omitInclude($url));
|
||||
$this->assertSession()->statusCodeEquals(400);
|
||||
|
||||
$session->visit($this->invalidInclude($url));
|
||||
$this->assertSession()->statusCodeEquals(400);
|
||||
|
||||
$session->visit($this->invalidExclude($url));
|
||||
$this->assertSession()->statusCodeEquals(400);
|
||||
|
||||
$session->visit($this->replaceFileNamePrefix($url));
|
||||
$this->assertSession()->statusCodeEquals(400);
|
||||
|
||||
$session->visit($this->setInvalidLibrary($url));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
// When an invalid asset hash name is given.
|
||||
$session->visit($this->replaceGroupHash($url));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$current_url = $session->getCurrentUrl();
|
||||
// Redirect to the correct one.
|
||||
$this->assertEquals($url, $current_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the delta in the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the delta replaced.
|
||||
*/
|
||||
protected function replaceGroupDelta(string $url): string {
|
||||
$parts = UrlHelper::parse($url);
|
||||
$parts['query']['delta'] = 100;
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the group hash in the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the group hash replaced.
|
||||
*/
|
||||
protected function replaceGroupHash(string $url): string {
|
||||
$parts = explode('_', $url, 2);
|
||||
$hash = strtok($parts[1], '.');
|
||||
$parts[1] = str_replace($hash, 'abcdefghijklmnop', $parts[1]);
|
||||
return $this->getAbsoluteUrl(implode('_', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the filename prefix in the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the file name prefix replaced.
|
||||
*/
|
||||
protected function replaceFileNamePrefix(string $url): string {
|
||||
return str_replace(['/css_', '/js_'], '/xyz_', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the 'include' entry in the given URL with an invalid value.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the 'include' query set to an invalid value.
|
||||
*/
|
||||
protected function setInvalidLibrary(string $url): string {
|
||||
// First replace the hash, so we don't get served the actual file on disk.
|
||||
$url = $this->replaceGroupHash($url);
|
||||
$parts = UrlHelper::parse($url);
|
||||
$include = explode(',', UrlHelper::uncompressQueryParameter($parts['query']['include']));
|
||||
$include[] = 'system/llama';
|
||||
$parts['query']['include'] = UrlHelper::compressQueryParameter(implode(',', $include));
|
||||
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the 'theme' query parameter from the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the 'theme' omitted.
|
||||
*/
|
||||
protected function omitTheme(string $url): string {
|
||||
// First replace the hash, so we don't get served the actual file on disk.
|
||||
$url = $this->replaceGroupHash($url);
|
||||
$parts = UrlHelper::parse($url);
|
||||
unset($parts['query']['theme']);
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the 'include' query parameter from the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with the 'include' parameter omitted.
|
||||
*/
|
||||
protected function omitInclude(string $url): string {
|
||||
// First replace the hash, so we don't get served the actual file on disk.
|
||||
$url = $this->replaceGroupHash($url);
|
||||
$parts = UrlHelper::parse($url);
|
||||
unset($parts['query']['include']);
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the 'include' query parameter with an invalid value.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with 'include' set to an arbitrary string.
|
||||
*/
|
||||
protected function invalidInclude(string $url): string {
|
||||
// First replace the hash, so we don't get served the actual file on disk.
|
||||
$url = $this->replaceGroupHash($url);
|
||||
$parts = UrlHelper::parse($url);
|
||||
$parts['query']['include'] = 'abcdefghijklmnop';
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an invalid 'exclude' query parameter with an invalid value.
|
||||
*
|
||||
* @param string $url
|
||||
* The source URL.
|
||||
*
|
||||
* @return string
|
||||
* The URL with 'exclude' set to an arbitrary string.
|
||||
*/
|
||||
protected function invalidExclude(string $url): string {
|
||||
// First replace the hash, so we don't get served the actual file on disk.
|
||||
$url = $this->replaceGroupHash($url);
|
||||
$parts = UrlHelper::parse($url);
|
||||
$parts['query']['exclude'] = 'abcdefghijklmnop';
|
||||
$query = UrlHelper::buildQuery($parts['query']);
|
||||
return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Asset;
|
||||
|
||||
/**
|
||||
* Tests asset aggregation with the Umami install profile.
|
||||
*
|
||||
* Umami includes several core modules as well as the Claro theme, this
|
||||
* results in a more complex asset dependency tree to test than the testing
|
||||
* profile.
|
||||
*
|
||||
* @group asset
|
||||
* @group #slow
|
||||
*/
|
||||
class AssetOptimizationUmamiTest extends AssetOptimizationTest {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $profile = 'demo_umami';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function requestPage(): void {
|
||||
$user = $this->createUser([], NULL, TRUE);
|
||||
$this->drupalLogin($user);
|
||||
$this->drupalGet('node/add/article');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Asset;
|
||||
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests asset aggregation.
|
||||
*
|
||||
* @group asset
|
||||
*/
|
||||
class UnversionedAssetTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* The file assets path settings value.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fileAssetsPath;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['system', 'unversioned_assets_test'];
|
||||
|
||||
/**
|
||||
* Tests that unversioned assets cause a new filename when changed.
|
||||
*/
|
||||
public function testUnversionedAssets(): void {
|
||||
$this->fileAssetsPath = $this->publicFilesDirectory;
|
||||
file_put_contents('public://test.css', '.original-content{display:none;}');
|
||||
// Test aggregation with a custom file_assets_path.
|
||||
$this->config('system.performance')->set('css', [
|
||||
'preprocess' => TRUE,
|
||||
'gzip' => TRUE,
|
||||
])->save();
|
||||
$this->config('system.performance')->set('js', [
|
||||
'preprocess' => TRUE,
|
||||
'gzip' => TRUE,
|
||||
])->save();
|
||||
|
||||
// Ensure that the library discovery cache is empty before the page is
|
||||
// requested and that updated asset URLs are rendered.
|
||||
\Drupal::service('cache.data')->deleteAll();
|
||||
\Drupal::service('cache.page')->deleteAll();
|
||||
$this->drupalGet('<front>');
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
|
||||
$style_elements = $page->findAll('xpath', '//link[@href and @rel="stylesheet"]');
|
||||
$this->assertNotEmpty($style_elements);
|
||||
$href = NULL;
|
||||
foreach ($style_elements as $element) {
|
||||
$href = $element->getAttribute('href');
|
||||
$url = $this->getAbsoluteUrl($href);
|
||||
// Not every script or style on a page is aggregated.
|
||||
if (!str_contains($url, $this->fileAssetsPath)) {
|
||||
continue;
|
||||
}
|
||||
$session = $this->getSession();
|
||||
$session->visit($url);
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$aggregate = $session = $session->getPage()->getContent();
|
||||
$this->assertStringContainsString('original-content', $aggregate);
|
||||
$this->assertStringNotContainsString('extra-stuff', $aggregate);
|
||||
}
|
||||
$file = file_get_contents('public://test.css') . '.extra-stuff{display:none;}';
|
||||
file_put_contents('public://test.css', $file);
|
||||
// Clear the library discovery and page caches again so that new URLs are
|
||||
// generated.
|
||||
\Drupal::service('cache.data')->deleteAll();
|
||||
\Drupal::service('cache.page')->deleteAll();
|
||||
$this->drupalGet('<front>');
|
||||
$session = $this->getSession();
|
||||
$page = $session->getPage();
|
||||
$style_elements = $page->findAll('xpath', '//link[@href and @rel="stylesheet"]');
|
||||
$this->assertNotEmpty($style_elements);
|
||||
foreach ($style_elements as $element) {
|
||||
$new_href = $element->getAttribute('href');
|
||||
$this->assertNotSame($new_href, $href);
|
||||
$url = $this->getAbsoluteUrl($new_href);
|
||||
// Not every script or style on a page is aggregated.
|
||||
if (!str_contains($url, $this->fileAssetsPath)) {
|
||||
continue;
|
||||
}
|
||||
$session = $this->getSession();
|
||||
$session->visit($url);
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$aggregate = $session = $session->getPage()->getContent();
|
||||
$this->assertStringContainsString('original-content', $aggregate);
|
||||
$this->assertStringContainsString('extra-stuff', $aggregate);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Bootstrap;
|
||||
|
||||
use Drupal\Core\DependencyInjection\Container;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Container base class which triggers an error.
|
||||
*/
|
||||
class ErrorContainer extends Container {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function get($id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE): ?object {
|
||||
if ($id === 'http_kernel') {
|
||||
// Enforce a recoverable error.
|
||||
$callable = function (ErrorContainer $container) {
|
||||
};
|
||||
return $callable(1);
|
||||
}
|
||||
return parent::get($id, $invalidBehavior);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Bootstrap;
|
||||
|
||||
use Drupal\Core\DependencyInjection\Container;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Base container which throws an exception.
|
||||
*/
|
||||
class ExceptionContainer extends Container {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function get($id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE): ?object {
|
||||
if ($id === 'http_kernel') {
|
||||
throw new \Exception('Thrown exception during Container::get');
|
||||
}
|
||||
else {
|
||||
return parent::get($id, $invalidBehavior);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Bootstrap;
|
||||
|
||||
use Drupal\Component\Render\FormattableMarkup;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests kernel panic when things are really messed up.
|
||||
*
|
||||
* @group system
|
||||
*/
|
||||
class UncaughtExceptionTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* Exceptions thrown by site under test that contain this text are ignored.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $expectedExceptionMessage;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['error_service_test', 'error_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$settings_filename = $this->siteDirectory . '/settings.php';
|
||||
chmod($settings_filename, 0777);
|
||||
$settings_php = file_get_contents($settings_filename);
|
||||
$settings_php .= "\ninclude_once 'core/tests/Drupal/FunctionalTests/Bootstrap/ErrorContainer.php';\n";
|
||||
$settings_php .= "\ninclude_once 'core/tests/Drupal/FunctionalTests/Bootstrap/ExceptionContainer.php';\n";
|
||||
// Ensure we can test errors rather than being caught in
|
||||
// \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware.
|
||||
$settings_php .= "\ndefine('SIMPLETEST_COLLECT_ERRORS', FALSE);\n";
|
||||
file_put_contents($settings_filename, $settings_php);
|
||||
|
||||
$settings = [];
|
||||
$settings['config']['system.logging']['error_level'] = (object) [
|
||||
'value' => ERROR_REPORTING_DISPLAY_VERBOSE,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->writeSettings($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests uncaught exception handling when system is in a bad state.
|
||||
*/
|
||||
public function testUncaughtException(): void {
|
||||
$this->expectedExceptionMessage = 'Oh oh, bananas in the instruments.';
|
||||
\Drupal::state()->set('error_service_test.break_bare_html_renderer', TRUE);
|
||||
|
||||
$settings = [];
|
||||
$settings['config']['system.logging']['error_level'] = (object) [
|
||||
'value' => ERROR_REPORTING_HIDE,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->writeSettings($settings);
|
||||
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
$this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
|
||||
$this->assertSession()->pageTextNotContains($this->expectedExceptionMessage);
|
||||
|
||||
$settings = [];
|
||||
$settings['config']['system.logging']['error_level'] = (object) [
|
||||
'value' => ERROR_REPORTING_DISPLAY_ALL,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->writeSettings($settings);
|
||||
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
$this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
|
||||
$this->assertSession()->pageTextContains($this->expectedExceptionMessage);
|
||||
$this->assertErrorLogged($this->expectedExceptionMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests displaying an uncaught fatal error.
|
||||
*/
|
||||
public function testUncaughtFatalError(): void {
|
||||
if (PHP_VERSION_ID >= 80400) {
|
||||
$fatal_error = [
|
||||
'%type' => 'TypeError',
|
||||
'@message' => 'Drupal\error_test\Controller\ErrorTestController::{closure:Drupal\error_test\Controller\ErrorTestController::generateFatalErrors():64}(): Argument #1 ($test) must be of type array, string given, called in ' . \Drupal::root() . '/core/modules/system/tests/modules/error_test/src/Controller/ErrorTestController.php on line 67',
|
||||
'%function' => 'Drupal\error_test\Controller\ErrorTestController->{closure:Drupal\error_test\Controller\ErrorTestController::generateFatalErrors():64}()',
|
||||
];
|
||||
}
|
||||
else {
|
||||
$fatal_error = [
|
||||
'%type' => 'TypeError',
|
||||
'@message' => 'Drupal\error_test\Controller\ErrorTestController::Drupal\error_test\Controller\{closure}(): Argument #1 ($test) must be of type array, string given, called in ' . \Drupal::root() . '/core/modules/system/tests/modules/error_test/src/Controller/ErrorTestController.php on line 67',
|
||||
'%function' => 'Drupal\error_test\Controller\ErrorTestController->Drupal\error_test\Controller\{closure}()',
|
||||
];
|
||||
}
|
||||
$this->drupalGet('error-test/generate-fatal-errors');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
$message = new FormattableMarkup('%type: @message in %function (line ', $fatal_error);
|
||||
$this->assertSession()->responseContains((string) $message);
|
||||
$this->assertSession()->responseContains('<pre class="backtrace">');
|
||||
// Ensure we are escaping but not double escaping.
|
||||
$this->assertSession()->responseContains('>');
|
||||
$this->assertSession()->responseNotContains('&gt;');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests uncaught exception handling with custom exception handler.
|
||||
*/
|
||||
public function testUncaughtExceptionCustomExceptionHandler(): void {
|
||||
$settings_filename = $this->siteDirectory . '/settings.php';
|
||||
chmod($settings_filename, 0777);
|
||||
$settings_php = file_get_contents($settings_filename);
|
||||
$settings_php .= "\n";
|
||||
$settings_php .= "set_exception_handler(function() {\n";
|
||||
$settings_php .= " header('HTTP/1.1 418 I\'m a teapot');\n";
|
||||
$settings_php .= " print('Oh oh, flying teapots');\n";
|
||||
$settings_php .= "});\n";
|
||||
file_put_contents($settings_filename, $settings_php);
|
||||
|
||||
\Drupal::state()->set('error_service_test.break_bare_html_renderer', TRUE);
|
||||
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(418);
|
||||
$this->assertSession()->pageTextNotContains('The website encountered an unexpected error. Try again later.');
|
||||
$this->assertSession()->pageTextNotContains('Oh oh, bananas in the instruments');
|
||||
$this->assertSession()->pageTextContains('Oh oh, flying teapots');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a missing dependency on a service.
|
||||
*/
|
||||
public function testMissingDependency(): void {
|
||||
$this->expectedExceptionMessage = 'Too few arguments to function Drupal\error_service_test\LonelyMonkeyClass::__construct(), 0 passed';
|
||||
$this->drupalGet('broken-service-class');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
|
||||
$this->assertSession()->pageTextContains('The website encountered an unexpected error.');
|
||||
$this->assertSession()->pageTextContains($this->expectedExceptionMessage);
|
||||
$this->assertErrorLogged($this->expectedExceptionMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a container which has an error.
|
||||
*/
|
||||
public function testErrorContainer(): void {
|
||||
$settings = [];
|
||||
$settings['settings']['container_base_class'] = (object) [
|
||||
'value' => '\Drupal\FunctionalTests\Bootstrap\ErrorContainer',
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->writeSettings($settings);
|
||||
\Drupal::service('kernel')->invalidateContainer();
|
||||
|
||||
$this->expectedExceptionMessage = PHP_VERSION_ID >= 80400 ?
|
||||
'Drupal\FunctionalTests\Bootstrap\ErrorContainer::{closure:Drupal\FunctionalTests\Bootstrap\ErrorContainer::get():21}(): Argument #1 ($container) must be of type Drupal\FunctionalTests\Bootstrap\ErrorContainer' :
|
||||
'Drupal\FunctionalTests\Bootstrap\ErrorContainer::Drupal\FunctionalTests\Bootstrap\{closure}(): Argument #1 ($container) must be of type Drupal\FunctionalTests\Bootstrap\ErrorContainer';
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
|
||||
$this->assertSession()->pageTextContains($this->expectedExceptionMessage);
|
||||
$this->assertErrorLogged($this->expectedExceptionMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a container which has an exception really early.
|
||||
*/
|
||||
public function testExceptionContainer(): void {
|
||||
$settings = [];
|
||||
$settings['settings']['container_base_class'] = (object) [
|
||||
'value' => '\Drupal\FunctionalTests\Bootstrap\ExceptionContainer',
|
||||
'required' => TRUE,
|
||||
];
|
||||
$this->writeSettings($settings);
|
||||
\Drupal::service('kernel')->invalidateContainer();
|
||||
|
||||
$this->expectedExceptionMessage = 'Thrown exception during Container::get';
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
|
||||
$this->assertSession()->pageTextContains('The website encountered an unexpected error');
|
||||
$this->assertSession()->pageTextContains($this->expectedExceptionMessage);
|
||||
$this->assertErrorLogged($this->expectedExceptionMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the case when the database connection is gone.
|
||||
*/
|
||||
public function testLostDatabaseConnection(): void {
|
||||
$incorrect_username = $this->randomMachineName(16);
|
||||
switch ($this->container->get('database')->driver()) {
|
||||
case 'pgsql':
|
||||
case 'mysql':
|
||||
$this->expectedExceptionMessage = $incorrect_username;
|
||||
break;
|
||||
|
||||
default:
|
||||
// We can not carry out this test.
|
||||
$this->markTestSkipped('Unable to run \Drupal\system\Tests\System\UncaughtExceptionTest::testLostDatabaseConnection for this database type.');
|
||||
}
|
||||
|
||||
// We simulate a broken database connection by rewrite settings.php to no
|
||||
// longer have the proper data.
|
||||
$settings['databases']['default']['default']['username'] = (object) [
|
||||
'value' => $incorrect_username,
|
||||
'required' => TRUE,
|
||||
];
|
||||
$settings['databases']['default']['default']['password'] = (object) [
|
||||
'value' => $this->randomMachineName(16),
|
||||
'required' => TRUE,
|
||||
];
|
||||
|
||||
$this->writeSettings($settings);
|
||||
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
$this->assertSession()->pageTextContains('DatabaseAccessDeniedException');
|
||||
$this->assertErrorLogged($this->expectedExceptionMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests fallback to PHP error log when an exception is thrown while logging.
|
||||
*/
|
||||
public function testLoggerException(): void {
|
||||
// Ensure the test error log is empty before these tests.
|
||||
$this->assertNoErrorsLogged();
|
||||
|
||||
$this->expectedExceptionMessage = 'Deforestation';
|
||||
\Drupal::state()->set('error_service_test.break_logger', TRUE);
|
||||
|
||||
$this->drupalGet('');
|
||||
$this->assertSession()->statusCodeEquals(500);
|
||||
$this->assertSession()->pageTextContains('The website encountered an unexpected error. Try again later.');
|
||||
$this->assertSession()->pageTextContains($this->expectedExceptionMessage);
|
||||
|
||||
// Find fatal error logged to the error.log
|
||||
$errors = file(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
|
||||
$this->assertCount(10, $errors, 'The error + the error that the logging service is broken has been written to the error log.');
|
||||
$this->assertStringContainsString('Failed to log error', $errors[0], 'The error handling logs when an error could not be logged to the logger.');
|
||||
|
||||
$expected_path = \Drupal::root() . '/core/modules/system/tests/modules/error_service_test/src/MonkeysInTheControlRoom.php';
|
||||
$expected_line = 69;
|
||||
$expected_entry = "Failed to log error: Exception: Deforestation in Drupal\\error_service_test\\MonkeysInTheControlRoom->handle() (line {$expected_line} of {$expected_path})";
|
||||
$this->assertStringContainsString($expected_entry, $errors[0], 'Original error logged to the PHP error log when an exception is thrown by a logger');
|
||||
|
||||
// The exception is expected. Do not interpret it as a test failure. Not
|
||||
// using File API; a potential error must trigger a PHP warning.
|
||||
unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a specific error has been logged to the PHP error log.
|
||||
*
|
||||
* @param string $error_message
|
||||
* The expected error message.
|
||||
*
|
||||
* @see \Drupal\Core\Test\FunctionalTestSetupTrait::prepareEnvironment()
|
||||
* @see \Drupal\Core\DrupalKernel::bootConfiguration()
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertErrorLogged(string $error_message): void {
|
||||
$error_log_filename = DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log';
|
||||
$this->assertFileExists($error_log_filename);
|
||||
|
||||
$content = file_get_contents($error_log_filename);
|
||||
$rows = explode(PHP_EOL, $content);
|
||||
|
||||
// We iterate over the rows in order to be able to remove the logged error
|
||||
// afterwards.
|
||||
$found = FALSE;
|
||||
foreach ($rows as $row_index => $row) {
|
||||
if (str_contains($content, $error_message)) {
|
||||
$found = TRUE;
|
||||
unset($rows[$row_index]);
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($error_log_filename, implode("\n", $rows));
|
||||
|
||||
$this->assertTrue($found, sprintf('The %s error message was logged.', $error_message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no errors have been logged to the PHP error.log thus far.
|
||||
*
|
||||
* @see \Drupal\Core\Test\FunctionalTestSetupTrait::prepareEnvironment()
|
||||
* @see \Drupal\Core\DrupalKernel::bootConfiguration()
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function assertNoErrorsLogged(): void {
|
||||
// Since PHP only creates the error.log file when an actual error is
|
||||
// triggered, it is sufficient to check whether the file exists.
|
||||
$this->assertFileDoesNotExist(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Breadcrumb;
|
||||
|
||||
use Drupal\Tests\block\Traits\BlockCreationTrait;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the breadcrumb of 404 pages.
|
||||
*
|
||||
* @group breadcrumb
|
||||
*/
|
||||
class Breadcrumb404Test extends BrowserTestBase {
|
||||
|
||||
use BlockCreationTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['system', 'block'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests that different 404s don't create unnecessary cache entries.
|
||||
*/
|
||||
public function testBreadcrumbOn404Pages(): void {
|
||||
$this->placeBlock('system_breadcrumb_block', ['id' => 'breadcrumb']);
|
||||
|
||||
// Prime the cache first.
|
||||
$this->drupalGet('/not-found-1');
|
||||
$base_count = count($this->getBreadcrumbCacheEntries());
|
||||
|
||||
$this->drupalGet('/not-found-2');
|
||||
$next_count = count($this->getBreadcrumbCacheEntries());
|
||||
$this->assertEquals($base_count, $next_count);
|
||||
|
||||
$this->drupalGet('/not-found-3');
|
||||
$next_count = count($this->getBreadcrumbCacheEntries());
|
||||
$this->assertEquals($base_count, $next_count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the breadcrumb cache entries.
|
||||
*
|
||||
* @return array
|
||||
* The breadcrumb cache entries.
|
||||
*/
|
||||
protected function getBreadcrumbCacheEntries() {
|
||||
$database = \Drupal::database();
|
||||
$cache_entries = $database->select('cache_render')
|
||||
->fields('cache_render')
|
||||
->condition('cid', $database->escapeLike('entity_view:block:breadcrumb') . '%', 'LIKE')
|
||||
->execute()
|
||||
->fetchAllAssoc('cid');
|
||||
return $cache_entries;
|
||||
}
|
||||
|
||||
}
|
||||
652
web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php
Normal file
652
web/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php
Normal file
@ -0,0 +1,652 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests;
|
||||
|
||||
use Behat\Mink\Exception\ElementNotFoundException;
|
||||
use Behat\Mink\Exception\ExpectationException;
|
||||
use Drupal\Component\Serialization\Json;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\Traits\Core\CronRunTrait;
|
||||
use Drupal\Tests\Traits\Core\PathAliasTestTrait;
|
||||
use Drupal\TestTools\Extension\Dump\DebugDump;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
// cspell:ignore htkey
|
||||
|
||||
/**
|
||||
* Tests BrowserTestBase functionality.
|
||||
*
|
||||
* @group browsertestbase
|
||||
*/
|
||||
class BrowserTestBaseTest extends BrowserTestBase {
|
||||
use PathAliasTestTrait;
|
||||
use CronRunTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'test_page_test',
|
||||
'form_test',
|
||||
'system_test',
|
||||
'node',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests that JavaScript Drupal settings can be read.
|
||||
*/
|
||||
public function testDrupalSettings(): void {
|
||||
// Trigger a 403 because those pages have very little else going on.
|
||||
$this->drupalGet('admin');
|
||||
$this->assertSame([], $this->getDrupalSettings());
|
||||
|
||||
// Now try the same 403 as an authenticated user and verify that Drupal
|
||||
// settings do show up.
|
||||
$account = $this->drupalCreateUser();
|
||||
$this->drupalLogin($account);
|
||||
$this->drupalGet('admin');
|
||||
$this->assertNotSame([], $this->getDrupalSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests basic page test.
|
||||
*/
|
||||
public function testGoTo(): void {
|
||||
$account = $this->drupalCreateUser();
|
||||
$this->drupalLogin($account);
|
||||
|
||||
// Visit a Drupal page that requires login.
|
||||
$this->drupalGet('test-page');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
// Test page contains some text.
|
||||
$this->assertSession()->pageTextContains('Test page text.');
|
||||
|
||||
// Check that returned plain text is correct.
|
||||
$text = $this->getTextContent();
|
||||
$this->assertStringContainsString('Test page text.', $text);
|
||||
$this->assertStringNotContainsString('</html>', $text);
|
||||
// Ensure Drupal Javascript settings are not part of the page text.
|
||||
$this->assertArrayHasKey('currentPathIsAdmin', $this->getDrupalSettings()['path']);
|
||||
$this->assertStringNotContainsString('currentPathIsAdmin', $text);
|
||||
|
||||
// Response includes cache tags that we can assert.
|
||||
$this->assertSession()->responseHeaderExists('X-Drupal-Cache-Tags');
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Tags', 'http_response rendered');
|
||||
|
||||
// Test that we can read the JS settings.
|
||||
$js_settings = $this->getDrupalSettings();
|
||||
$this->assertSame('azAZ09();.,\\\/-_{}', $js_settings['test-setting']);
|
||||
|
||||
// Test drupalGet with a URL object.
|
||||
$url = Url::fromRoute('test_page_test.render_title');
|
||||
$this->drupalGet($url);
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
// Test page contains some text.
|
||||
$this->assertSession()->pageTextContains('Hello Drupal');
|
||||
|
||||
// Test that setting headers with drupalGet() works.
|
||||
$this->drupalGet('system-test/header', [], [
|
||||
'Test-Header' => 'header value',
|
||||
]);
|
||||
$this->assertSession()->responseHeaderExists('Test-Header');
|
||||
$this->assertSession()->responseHeaderEquals('Test-Header', 'header value');
|
||||
|
||||
// Ensure that \Drupal\Tests\UiHelperTrait::isTestUsingGuzzleClient() works
|
||||
// as expected.
|
||||
$this->assertTrue($this->isTestUsingGuzzleClient());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests drupalGet().
|
||||
*/
|
||||
public function testDrupalGet(): void {
|
||||
$this->drupalGet('test-page');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('test-page');
|
||||
$this->drupalGet('/test-page');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('test-page');
|
||||
$this->drupalGet('/test-page/');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('/test-page/');
|
||||
// Test alias handling.
|
||||
$this->createPathAlias('/test-page', '/test-alias');
|
||||
$this->rebuildAll();
|
||||
$this->drupalGet('test-page');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('test-alias');
|
||||
$this->drupalGet('/test-page');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('test-alias');
|
||||
$this->drupalGet('/test-page/');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals('/test-page/');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests basic form functionality.
|
||||
*/
|
||||
public function testForm(): void {
|
||||
// Ensure the proper response code for a _form route.
|
||||
$this->drupalGet('form-test/object-builder');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
// Ensure the form and text field exist.
|
||||
$this->assertSession()->elementExists('css', 'form#form-test-form-test-object');
|
||||
$this->assertSession()->fieldExists('bananas');
|
||||
|
||||
// Check that the hidden field exists and has a specific value.
|
||||
$this->assertSession()->hiddenFieldExists('strawberry');
|
||||
$this->assertSession()->hiddenFieldExists('red');
|
||||
$this->assertSession()->hiddenFieldExists('red-strawberry-hidden-field');
|
||||
$this->assertSession()->hiddenFieldValueNotEquals('strawberry', 'brown');
|
||||
$this->assertSession()->hiddenFieldValueEquals('strawberry', 'red');
|
||||
|
||||
// Check that a hidden field does not exist.
|
||||
$this->assertSession()->hiddenFieldNotExists('bananas');
|
||||
$this->assertSession()->hiddenFieldNotExists('pineapple');
|
||||
|
||||
$edit = ['bananas' => 'green'];
|
||||
$this->submitForm($edit, 'Save', 'form-test-form-test-object');
|
||||
|
||||
$config_factory = $this->container->get('config.factory');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('green', $value);
|
||||
|
||||
// Test submitForm().
|
||||
$this->drupalGet('form-test/object-builder');
|
||||
|
||||
// Submit the form using the button label.
|
||||
$this->submitForm(['bananas' => 'red'], 'Save');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('red', $value);
|
||||
|
||||
$this->submitForm([], 'Save');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('', $value);
|
||||
|
||||
// Submit the form using the button id.
|
||||
$this->submitForm(['bananas' => 'blue'], 'edit-submit');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('blue', $value);
|
||||
|
||||
// Submit the form using the button name.
|
||||
$this->submitForm(['bananas' => 'purple'], 'op');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('purple', $value);
|
||||
|
||||
// Submit using the form attribute of a button.
|
||||
$this->drupalGet('form-test/button-form-attribute');
|
||||
$this->submitForm(['bananas' => 'purple'], 'Attribute Button');
|
||||
$value = $config_factory->get('form_test.object')->get('bananas');
|
||||
$this->assertSame('purple', $value);
|
||||
|
||||
// Test submitForm() with no-html response.
|
||||
$this->drupalGet('form_test/form-state-values-clean');
|
||||
$this->submitForm([], 'Submit');
|
||||
$values = Json::decode($this->getSession()->getPage()->getContent());
|
||||
$this->assertSame(1000, $values['beer']);
|
||||
|
||||
// Test submitForm() with form by HTML id.
|
||||
$this->drupalCreateContentType(['type' => 'page']);
|
||||
$this->drupalLogin($this->drupalCreateUser(['create page content']));
|
||||
$this->drupalGet('form-test/two-instances-of-same-form');
|
||||
$this->getSession()->getPage()->fillField('edit-title-0-value', 'form1');
|
||||
$this->getSession()->getPage()->fillField('edit-title-0-value--2', 'form2');
|
||||
$this->submitForm([], 'Save', 'node-page-form--2');
|
||||
$this->assertSession()->pageTextContains('Page form2 has been created.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests clickLink() functionality.
|
||||
*/
|
||||
public function testClickLink(): void {
|
||||
$this->drupalGet('test-page');
|
||||
$this->clickLink('Visually identical test links');
|
||||
$this->assertStringContainsString('user/login', $this->getSession()->getCurrentUrl());
|
||||
$this->drupalGet('test-page');
|
||||
$this->clickLink('Visually identical test links', 0);
|
||||
$this->assertStringContainsString('user/login', $this->getSession()->getCurrentUrl());
|
||||
$this->drupalGet('test-page');
|
||||
$this->clickLink('Visually identical test links', 1);
|
||||
$this->assertStringContainsString('user/register', $this->getSession()->getCurrentUrl());
|
||||
}
|
||||
|
||||
public function testError(): void {
|
||||
$this->expectException('\Exception');
|
||||
$this->expectExceptionMessage('User notice: foo');
|
||||
$this->drupalGet('test-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests legacy field asserts which use xpath directly.
|
||||
*/
|
||||
public function testXpathAsserts(): void {
|
||||
$this->drupalGet('test-field-xpath');
|
||||
$this->assertSession()->elementTextContains('xpath', '//table/tbody/tr[2]/td[1]', 'one');
|
||||
|
||||
$this->assertSession()->fieldValueEquals('edit-name', 'Test name');
|
||||
$this->assertSession()->fieldValueEquals('edit-options', '2');
|
||||
|
||||
$this->assertSession()->elementNotExists('xpath', '//nonexisting');
|
||||
$this->assertSession()->fieldValueNotEquals('edit-name', 'wrong value');
|
||||
|
||||
// Test that the assertion fails correctly.
|
||||
try {
|
||||
$this->assertSession()->fieldExists('nonexisting');
|
||||
$this->fail('The "nonexisting" field was found.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('edit-name');
|
||||
$this->fail('The "edit-name" field was not found.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests field asserts using textfields.
|
||||
*/
|
||||
public function testFieldAssertsForTextfields(): void {
|
||||
$this->drupalGet('test-field-xpath');
|
||||
|
||||
// *** 1. fieldNotExists().
|
||||
$this->assertSession()->fieldNotExists('invalid_name_and_id');
|
||||
|
||||
// Test that the assertion fails correctly when searching by name.
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('name');
|
||||
$this->fail('The "name" field was not found based on name.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that the assertion fails correctly when searching by id.
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('edit-name');
|
||||
$this->fail('The "name" field was not found based on id.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// *** 2. fieldExists().
|
||||
$this->assertSession()->fieldExists('name');
|
||||
$this->assertSession()->fieldExists('edit-name');
|
||||
|
||||
// Test that the assertion fails correctly if the field does not exist.
|
||||
try {
|
||||
$this->assertSession()->fieldExists('invalid_name_and_id');
|
||||
$this->fail('The "invalid_name_and_id" field was found.');
|
||||
}
|
||||
catch (ElementNotFoundException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
// *** 3. assertNoFieldById().
|
||||
$this->assertSession()->fieldValueNotEquals('name', 'not the value');
|
||||
$this->assertSession()->fieldNotExists('nonexisting');
|
||||
// Test that the assertion fails correctly if no value is passed in.
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('edit-description');
|
||||
$this->fail('The "description" field, with no value was not found.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that the assertion fails correctly if a NULL value is passed in.
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('name', NULL);
|
||||
$this->fail('The "name" field was not found.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// *** 4. assertFieldById().
|
||||
$this->assertSession()->fieldExists('edit-name');
|
||||
$this->assertSession()->fieldValueEquals('edit-name', 'Test name');
|
||||
$this->assertSession()->fieldExists('edit-description');
|
||||
$this->assertSession()->fieldValueEquals('edit-description', '');
|
||||
|
||||
// Test that the assertion fails correctly if no value is passed in.
|
||||
try {
|
||||
$this->assertSession()->fieldValueNotEquals('edit-name', '');
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that the assertion fails correctly if the wrong value is passed in.
|
||||
try {
|
||||
$this->assertSession()->fieldValueNotEquals('edit-name', 'not the value');
|
||||
}
|
||||
catch (ExpectationFailedException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// *** 5. fieldValueNotEquals().
|
||||
$this->assertSession()->fieldValueNotEquals('name', 'not the value');
|
||||
|
||||
// Test that the assertion fails correctly if given the right value.
|
||||
try {
|
||||
$this->assertSession()->fieldValueNotEquals('name', 'Test name');
|
||||
$this->fail('fieldValueNotEquals failed to throw an exception.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// *** 6. fieldValueEquals().
|
||||
$this->assertSession()->fieldValueEquals('name', 'Test name');
|
||||
$this->assertSession()->fieldValueEquals('description', '');
|
||||
|
||||
// Test that the assertion fails correctly if given the wrong value.
|
||||
try {
|
||||
$this->assertSession()->fieldValueEquals('name', 'not the value');
|
||||
$this->fail('fieldValueEquals failed to throw an exception.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that text areas can contain new lines.
|
||||
$this->assertSession()->fieldValueEquals('edit-test-textarea-with-newline', "Test text with\nnewline");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests legacy field asserts for checkbox field type.
|
||||
*/
|
||||
public function testFieldAssertsForCheckbox(): void {
|
||||
$this->drupalGet('test-field-xpath');
|
||||
|
||||
// Part 1 - Test by name.
|
||||
// Test that checkboxes are found/not found correctly by name, when using
|
||||
// '1' or '' to match their 'checked' state.
|
||||
$this->assertSession()->fieldExists('checkbox_enabled');
|
||||
$this->assertSession()->fieldExists('checkbox_disabled');
|
||||
$this->assertSession()->fieldValueEquals('checkbox_enabled', '1');
|
||||
$this->assertSession()->fieldValueEquals('checkbox_disabled', '');
|
||||
$this->assertSession()->fieldValueNotEquals('checkbox_enabled', '');
|
||||
$this->assertSession()->fieldValueNotEquals('checkbox_disabled', '1');
|
||||
|
||||
// Test that we have legacy support.
|
||||
$this->assertSession()->fieldValueEquals('checkbox_enabled', '1');
|
||||
$this->assertSession()->fieldValueEquals('checkbox_disabled', '');
|
||||
|
||||
// Test that the assertion fails correctly if given the right value.
|
||||
try {
|
||||
$this->assertSession()->fieldValueNotEquals('checkbox_enabled', '1');
|
||||
$this->fail('fieldValueNotEquals failed to throw an exception.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Part 2 - Test by ID.
|
||||
// Test that checkboxes are found/not found correctly by ID, when using
|
||||
// '1' or '' to match their 'checked' state.
|
||||
$this->assertSession()->fieldValueEquals('edit-checkbox-enabled', '1');
|
||||
$this->assertSession()->fieldValueEquals('edit-checkbox-disabled', '');
|
||||
$this->assertSession()->fieldValueNotEquals('edit-checkbox-enabled', '');
|
||||
$this->assertSession()->fieldValueNotEquals('edit-checkbox-disabled', '1');
|
||||
|
||||
// Test that checkboxes are found by ID, when using NULL to ignore the
|
||||
// 'checked' state.
|
||||
$this->assertSession()->fieldExists('edit-checkbox-enabled');
|
||||
$this->assertSession()->fieldExists('edit-checkbox-disabled');
|
||||
|
||||
// Test that checkboxes are found by ID when passing no second parameter.
|
||||
$this->assertSession()->fieldExists('edit-checkbox-enabled');
|
||||
$this->assertSession()->fieldExists('edit-checkbox-disabled');
|
||||
|
||||
// Test that we have legacy support.
|
||||
$this->assertSession()->fieldValueEquals('edit-checkbox-enabled', '1');
|
||||
$this->assertSession()->fieldValueEquals('edit-checkbox-disabled', '');
|
||||
|
||||
// Test that the assertion fails correctly when using NULL to ignore state.
|
||||
try {
|
||||
$this->assertSession()->fieldNotExists('edit-checkbox-disabled', NULL);
|
||||
$this->fail('The "edit-checkbox-disabled" field was not found by ID, using NULL value.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Part 3 - Test the specific 'checked' assertions.
|
||||
$this->assertSession()->checkboxChecked('edit-checkbox-enabled');
|
||||
$this->assertSession()->checkboxNotChecked('edit-checkbox-disabled');
|
||||
|
||||
// Test that the assertion fails correctly with non-existent field id.
|
||||
try {
|
||||
$this->assertSession()->checkboxNotChecked('incorrect_checkbox_id');
|
||||
$this->fail('The "incorrect_checkbox_id" field was found');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that the assertion fails correctly for a checkbox that is checked.
|
||||
try {
|
||||
$this->assertSession()->checkboxNotChecked('edit-checkbox-enabled');
|
||||
$this->fail('The "edit-checkbox-enabled" field was not found in a checked state.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
|
||||
// Test that the assertion fails correctly for a checkbox that is not
|
||||
// checked.
|
||||
try {
|
||||
$this->assertSession()->checkboxChecked('edit-checkbox-disabled');
|
||||
$this->fail('The "edit-checkbox-disabled" field was found and checked.');
|
||||
}
|
||||
catch (ExpectationException) {
|
||||
// Expected exception; just continue testing.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the ::cronRun() method.
|
||||
*/
|
||||
public function testCronRun(): void {
|
||||
$last_cron_time = \Drupal::state()->get('system.cron_last');
|
||||
$this->cronRun();
|
||||
$this->assertSession()->statusCodeEquals(204);
|
||||
$next_cron_time = \Drupal::state()->get('system.cron_last');
|
||||
|
||||
$this->assertGreaterThan($last_cron_time, $next_cron_time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the Drupal install done in \Drupal\Tests\BrowserTestBase::setUp().
|
||||
*/
|
||||
public function testInstall(): void {
|
||||
$htaccess_filename = $this->tempFilesDirectory . '/.htaccess';
|
||||
$this->assertFileExists($htaccess_filename);
|
||||
|
||||
// Ensure the Update Status module is not installed.
|
||||
$this->assertFalse(\Drupal::moduleHandler()->moduleExists('update'), 'The Update Status module should not be installed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the assumption that local time is in 'Australia/Sydney'.
|
||||
*/
|
||||
public function testLocalTimeZone(): void {
|
||||
$expected = 'Australia/Sydney';
|
||||
// The 'Australia/Sydney' time zone is set in core/tests/bootstrap.php
|
||||
$this->assertEquals($expected, date_default_timezone_get());
|
||||
|
||||
// The 'Australia/Sydney' time zone is also set in
|
||||
// FunctionalTestSetupTrait::initConfig().
|
||||
$config_factory = $this->container->get('config.factory');
|
||||
$value = $config_factory->get('system.date')->get('timezone.default');
|
||||
$this->assertEquals($expected, $value);
|
||||
|
||||
// Test that users have the correct time zone set.
|
||||
$this->assertEquals($expected, $this->rootUser->getTimeZone());
|
||||
$admin_user = $this->drupalCreateUser(['administer site configuration']);
|
||||
$this->assertEquals($expected, $admin_user->getTimeZone());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the ::checkForMetaRefresh() method.
|
||||
*/
|
||||
public function testCheckForMetaRefresh(): void {
|
||||
// Disable following redirects in the client.
|
||||
$this->getSession()->getDriver()->getClient()->followRedirects(FALSE);
|
||||
// Set the maximumMetaRefreshCount to zero to make sure the redirect doesn't
|
||||
// happen when doing a drupalGet.
|
||||
$this->maximumMetaRefreshCount = 0;
|
||||
$this->drupalGet('test-meta-refresh');
|
||||
$this->assertNotEmpty($this->cssSelect('meta[http-equiv="refresh"]'));
|
||||
// Allow one redirect to happen.
|
||||
$this->maximumMetaRefreshCount = 1;
|
||||
$this->checkForMetaRefresh();
|
||||
// Check that we are now on the test page.
|
||||
$this->assertSession()->pageTextContains('Test page text.');
|
||||
}
|
||||
|
||||
public function testGetDefaultDriveInstance(): void {
|
||||
putenv('MINK_DRIVER_ARGS=' . json_encode([NULL, ['key1' => ['key2' => ['key3' => 3, 'key3.1' => 3.1]]]]));
|
||||
$this->getDefaultDriverInstance();
|
||||
$this->assertEquals([NULL, ['key1' => ['key2' => ['key3' => 3, 'key3.1' => 3.1]]]], $this->minkDefaultDriverArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures we can't access modules we shouldn't be able to after install.
|
||||
*/
|
||||
public function testProfileModules(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The module demo_umami_content does not exist.');
|
||||
$this->assertFileExists('core/profiles/demo_umami/modules/demo_umami_content/demo_umami_content.info.yml');
|
||||
\Drupal::service('extension.list.module')->getPathname('demo_umami_content');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the protections provided by .htkey.
|
||||
*/
|
||||
public function testHtKey(): void {
|
||||
// Remove the Simpletest private key file so we can test the protection
|
||||
// against requests that forge a valid testing user agent to gain access
|
||||
// to the installer.
|
||||
// @see drupal_valid_test_ua()
|
||||
// Not using File API; a potential error must trigger a PHP warning.
|
||||
$install_url = Url::fromUri('base:core/install.php', ['external' => TRUE, 'absolute' => TRUE])->toString();
|
||||
$this->drupalGet($install_url);
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
unlink($this->siteDirectory . '/.htkey');
|
||||
$this->drupalGet($install_url);
|
||||
$this->assertSession()->statusCodeEquals(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a usable session is on the request in test-runner.
|
||||
*/
|
||||
public function testSessionOnRequest(): void {
|
||||
/** @var \Symfony\Component\HttpFoundation\Session\Session $session */
|
||||
$session = $this->container->get('request_stack')->getSession();
|
||||
|
||||
$session->set('some-val', 'do-not-cleanup');
|
||||
$this->assertEquals('do-not-cleanup', $session->get('some-val'));
|
||||
|
||||
$session->set('some-other-val', 'do-cleanup');
|
||||
$this->assertEquals('do-cleanup', $session->remove('some-other-val'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that deprecation headers do not get duplicated.
|
||||
*
|
||||
* @group legacy
|
||||
*
|
||||
* @see \Drupal\Core\Test\HttpClientMiddleware\TestHttpClientMiddleware::__invoke()
|
||||
*/
|
||||
public function testDeprecationHeaders(): void {
|
||||
$this->drupalGet('/test-deprecations');
|
||||
|
||||
$deprecation_messages = [];
|
||||
foreach ($this->getSession()->getResponseHeaders() as $name => $values) {
|
||||
if (preg_match('/^X-Drupal-Assertion-[0-9]+$/', $name, $matches)) {
|
||||
foreach ($values as $value) {
|
||||
$parameters = unserialize(urldecode($value));
|
||||
if (count($parameters) === 3) {
|
||||
if ($parameters[1] === 'User deprecated function') {
|
||||
$deprecation_messages[] = (string) $parameters[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertContains('Test deprecation message', $deprecation_messages);
|
||||
$test_deprecation_messages = array_filter($deprecation_messages, function ($message) {
|
||||
return $message === 'Test deprecation message';
|
||||
});
|
||||
$this->assertCount(1, $test_deprecation_messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the dump() function provided by the var-dumper Symfony component.
|
||||
*/
|
||||
public function testVarDump(): void {
|
||||
// Dump some variables.
|
||||
$object = (object) [
|
||||
'Aldebaran' => 'Betelgeuse',
|
||||
];
|
||||
dump($object);
|
||||
dump('Alpheratz');
|
||||
|
||||
$dumpString = json_encode(DebugDump::getDumps());
|
||||
|
||||
$this->assertStringContainsString('BrowserTestBaseTest::testVarDump', $dumpString);
|
||||
$this->assertStringContainsString('Aldebaran', $dumpString);
|
||||
$this->assertStringContainsString('Betelgeuse', $dumpString);
|
||||
$this->assertStringContainsString('Alpheratz', $dumpString);
|
||||
|
||||
// Visit a Drupal page with call to the dump() function to check that dump()
|
||||
// in site code produces output in the requested web page's HTML.
|
||||
$body = $this->drupalGet('test-page-var-dump');
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
|
||||
// It is too strict to assert all properties of the Role and it is easy to
|
||||
// break if one of these properties gets removed or gets a new default
|
||||
// value. It should be sufficient to test just a couple of properties.
|
||||
$this->assertStringContainsString('<span class=sf-dump-note>', $body);
|
||||
$this->assertStringContainsString(' #<span class=sf-dump-protected title="Protected property">id</span>: "<span class=sf-dump-str title="9 characters">test_role</span>"', $body);
|
||||
$this->assertStringContainsString(' #<span class=sf-dump-protected title="Protected property">label</span>: "<span class=sf-dump-str title="9 characters">Test role</span>"', $body);
|
||||
$this->assertStringContainsString(' #<span class=sf-dump-protected title="Protected property">permissions</span>: []', $body);
|
||||
$this->assertStringContainsString(' #<span class=sf-dump-protected title="Protected property">uuid</span>: "', $body);
|
||||
$this->assertStringContainsString('</samp>}', $body);
|
||||
|
||||
// Check that dump() in SUT did not leak into the test's dumps.
|
||||
$this->assertSame($dumpString, json_encode(DebugDump::getDumps()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests an invalid scheme in SIMPLETEST_BASE_URL throws an exception.
|
||||
*/
|
||||
public function testSimpleTestBaseUrlValidation(): void {
|
||||
putenv('SIMPLETEST_BASE_URL=mysql://user:pass@localhost/database');
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('You must provide valid scheme for the SIMPLETEST_BASE_URL environment variable. Valid schema are: http, https.');
|
||||
$this->setupBaseUrl();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests;
|
||||
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests BrowserTestBase functionality.
|
||||
*
|
||||
* @group browsertestbase
|
||||
*/
|
||||
class BrowserTestBaseUserAgentTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* The user agent string to use.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $agent;
|
||||
|
||||
/**
|
||||
* Tests validation of the User-Agent header we use to perform test requests.
|
||||
*/
|
||||
public function testUserAgentValidation(): void {
|
||||
$assert_session = $this->assertSession();
|
||||
$system_path = $this->buildUrl(\Drupal::service('extension.list.module')->getPath('system'));
|
||||
$http_path = $system_path . '/tests/http.php/user/login';
|
||||
$https_path = $system_path . '/tests/https.php/user/login';
|
||||
// Generate a valid test User-Agent to pass validation.
|
||||
$this->assertNotFalse(preg_match('/test\d+/', $this->databasePrefix, $matches), 'Database prefix contains test prefix.');
|
||||
$this->agent = drupal_generate_test_ua($matches[0]);
|
||||
|
||||
// Test pages only available for testing.
|
||||
$this->drupalGet($http_path);
|
||||
$assert_session->statusCodeEquals(200);
|
||||
$this->drupalGet($https_path);
|
||||
$assert_session->statusCodeEquals(200);
|
||||
|
||||
// Now slightly modify the HMAC on the header, which should not validate.
|
||||
$this->agent = 'X';
|
||||
$this->drupalGet($http_path);
|
||||
$assert_session->statusCodeEquals(403);
|
||||
$this->drupalGet($https_path);
|
||||
$assert_session->statusCodeEquals(403);
|
||||
|
||||
// Use a real User-Agent and verify that the special files http.php and
|
||||
// https.php can't be accessed.
|
||||
$this->agent = 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.6; en-US; rv:1.9.2.12) Gecko/20101026 Firefox/3.6.12';
|
||||
$this->drupalGet($http_path);
|
||||
$assert_session->statusCodeEquals(403);
|
||||
$this->drupalGet($https_path);
|
||||
$assert_session->statusCodeEquals(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function prepareRequest(): void {
|
||||
$session = $this->getSession();
|
||||
if ($this->agent) {
|
||||
$session->setCookie('SIMPLETEST_USER_AGENT', $this->agent);
|
||||
}
|
||||
else {
|
||||
$session->setCookie('SIMPLETEST_USER_AGENT', drupal_generate_test_ua($this->databasePrefix));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Components;
|
||||
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the correct rendering of components.
|
||||
*
|
||||
* @group sdc
|
||||
*/
|
||||
class ComponentRenderTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['system', 'sdc_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'sdc_theme_test';
|
||||
|
||||
/**
|
||||
* Tests libraryOverrides.
|
||||
*/
|
||||
public function testLibraryOverrides(): void {
|
||||
$build = [
|
||||
'#type' => 'inline_template',
|
||||
'#template' => "{{ include('sdc_theme_test:lib-overrides') }}",
|
||||
];
|
||||
\Drupal::state()->set('sdc_test_component', $build);
|
||||
$output = $this->drupalGet('sdc-test-component');
|
||||
$this->assertStringContainsString('another-stylesheet.css', $output);
|
||||
// Since libraryOverrides is taking control of CSS, and it's not listing
|
||||
// lib-overrides.css, then it should not be there. Even if it's the CSS
|
||||
// that usually gets auto-attached.
|
||||
$this->assertStringNotContainsString('lib-overrides.css', $output);
|
||||
// Ensure that libraryDependencies adds the expected assets.
|
||||
$this->assertStringContainsString('dialog.position.js', $output);
|
||||
// Ensure that libraryOverrides processes attributes properly.
|
||||
$this->assertMatchesRegularExpression('@<script.*src="[^"]*lib-overrides\.js\?v=1[^"]*".*defer.*bar="foo"></script>@', $output);
|
||||
// Ensure that libraryOverrides processes external CSS properly.
|
||||
$this->assertMatchesRegularExpression('@<link.*href="https://drupal\.org/fake-dependency/styles\.css" />@', $output);
|
||||
// Ensure that libraryOverrides processes external JS properly.
|
||||
$this->assertMatchesRegularExpression('@<script.*src="https://drupal\.org/fake-dependency/index\.min\.js"></script>@', $output);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Config;
|
||||
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\Traits\Core\Config\SchemaConfigListenerTestTrait;
|
||||
|
||||
/**
|
||||
* Tests the functionality of ConfigSchemaChecker in BrowserTestBase tests.
|
||||
*
|
||||
* @group config
|
||||
*/
|
||||
class SchemaConfigListenerTest extends BrowserTestBase {
|
||||
|
||||
use SchemaConfigListenerTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['config_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Container;
|
||||
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Test whether deprecation notices are triggered via \Drupal::service().
|
||||
*
|
||||
* Note: this test must be a BrowserTestBase so the container is properly
|
||||
* compiled. The container in KernelTestBase tests is always an instance of
|
||||
* \Drupal\Core\DependencyInjection\ContainerBuilder.
|
||||
*
|
||||
* @group Container
|
||||
* @group legacy
|
||||
*
|
||||
* @coversDefaultClass \Drupal\Component\DependencyInjection\Container
|
||||
*/
|
||||
class ServiceDeprecationTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['deprecation_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* @covers ::get
|
||||
*/
|
||||
public function testGetDeprecated(): void {
|
||||
$this->expectDeprecation('The "deprecation_test.service" service is deprecated in drupal:9.0.0 and is removed from drupal:20.0.0. This is a test.');
|
||||
$this->expectDeprecation('The "deprecation_test.alias" alias is deprecated in drupal:9.0.0 and is removed from drupal:20.0.0. This is a test.');
|
||||
// @phpstan-ignore-next-line
|
||||
\Drupal::service('deprecation_test.service');
|
||||
// @phpstan-ignore-next-line
|
||||
\Drupal::service('deprecation_test.alias');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Recipe;
|
||||
|
||||
use Drupal\contact\Entity\ContactForm;
|
||||
use Drupal\Core\Config\Checkpoint\Checkpoint;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests recipe command.
|
||||
*
|
||||
* BrowserTestBase is used for a proper Drupal install.
|
||||
*
|
||||
* @coversDefaultClass \Drupal\Core\Recipe\RecipeCommand
|
||||
* @group Recipe
|
||||
*/
|
||||
class RecipeCommandTest extends BrowserTestBase {
|
||||
|
||||
use RecipeTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Disable strict config schema because this test explicitly makes the
|
||||
* recipe system save invalid config, to prove that it validates it after
|
||||
* the fact and raises an error.
|
||||
*/
|
||||
protected $strictConfigSchema = FALSE;
|
||||
|
||||
public function testRecipeCommand(): void {
|
||||
$this->assertFalse(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is not installed');
|
||||
$this->assertCheckpointsExist([]);
|
||||
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/install_node_with_config');
|
||||
$this->assertSame(0, $process->getExitCode());
|
||||
$this->assertStringContainsString("Applied Install node with config recipe.", $process->getErrorOutput());
|
||||
$this->assertStringContainsString('Install node with config applied successfully', $process->getOutput());
|
||||
$this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
|
||||
$this->assertCheckpointsExist(["Backup before the 'Install node with config' recipe."]);
|
||||
|
||||
// Ensure recipes can be applied without affecting pre-existing checkpoints.
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/install_two_modules');
|
||||
$this->assertSame(0, $process->getExitCode());
|
||||
$this->assertStringContainsString("Applied Install two modules recipe.", $process->getErrorOutput());
|
||||
$this->assertStringContainsString('Install two modules applied successfully', $process->getOutput());
|
||||
$this->assertTrue(\Drupal::moduleHandler()->moduleExists('node'), 'The node module is installed');
|
||||
$this->assertCheckpointsExist([
|
||||
"Backup before the 'Install node with config' recipe.",
|
||||
"Backup before the 'Install two modules' recipe.",
|
||||
]);
|
||||
|
||||
// Ensure recipes that fail have an exception message.
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/invalid_config', 1);
|
||||
$this->assertStringContainsString("There were validation errors in core.date_format.invalid", $process->getErrorOutput());
|
||||
$this->assertCheckpointsExist([
|
||||
"Backup before the 'Install node with config' recipe.",
|
||||
"Backup before the 'Install two modules' recipe.",
|
||||
// Although the recipe command tried to create a checkpoint, it did not
|
||||
// actually happen, because of https://drupal.org/i/3408523.
|
||||
]);
|
||||
|
||||
// Create a checkpoint so we can test what happens when a recipe does not
|
||||
// create a checkpoint before applying.
|
||||
\Drupal::service('config.storage.checkpoint')->checkpoint('Test log message');
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/no_extensions');
|
||||
$this->assertSame(0, $process->getExitCode());
|
||||
$this->assertStringContainsString("Applied No extensions recipe.", $process->getErrorOutput());
|
||||
$this->assertCheckpointsExist([
|
||||
"Backup before the 'Install node with config' recipe.",
|
||||
"Backup before the 'Install two modules' recipe.",
|
||||
"Test log message",
|
||||
]);
|
||||
$this->assertStringContainsString('[notice] A backup checkpoint was not created because nothing has changed since the "Test log message" checkpoint was created.', $process->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that errors during config rollback won't steamroll validation errors.
|
||||
*/
|
||||
public function testExceptionOnRollback(): void {
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/config_rollback_exception', 1);
|
||||
|
||||
// The error from the config importer should be visible.
|
||||
$output = $process->getOutput();
|
||||
$this->assertStringContainsString('There were errors validating the config synchronization.', $output);
|
||||
$this->assertStringContainsString('Provides a filter plugin that is in use', $output);
|
||||
// And the exception that actually *caused* the error should be visible too.
|
||||
$this->assertStringContainsString('There were validation errors in system.image:', $process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the recipe command with a non-existent directory.
|
||||
*/
|
||||
public function testErrorOnNonExistentDirectory(): void {
|
||||
$process = $this->applyRecipe('core/tests/fixtures/recipes/does_not_exist', 1);
|
||||
|
||||
// The directory error should be the only error visible.
|
||||
$output = trim(preg_replace('/\s+/', ' ', $process->getOutput()));
|
||||
$this->assertSame('[ERROR] The supplied path core/tests/fixtures/recipes/does_not_exist is not a directory', $output);
|
||||
$this->assertEmpty($process->getErrorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the current set of checkpoints matches the given labels.
|
||||
*
|
||||
* @param string[] $expected_labels
|
||||
* The labels of every checkpoint that is expected to exist currently, in
|
||||
* the expected order.
|
||||
*/
|
||||
private function assertCheckpointsExist(array $expected_labels): void {
|
||||
$checkpoints = \Drupal::service('config.checkpoints');
|
||||
$labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
|
||||
$this->assertSame($expected_labels, array_values($labels));
|
||||
}
|
||||
|
||||
public function testPassInput(): void {
|
||||
$dir = $this->getDrupalRoot() . '/core/recipes/feedback_contact_form';
|
||||
$this->applyRecipe($dir, options: [
|
||||
'--input=feedback_contact_form.recipient=hello@good.bye',
|
||||
]);
|
||||
$this->assertSame(['hello@good.bye'], ContactForm::load('feedback')?->getRecipients());
|
||||
}
|
||||
|
||||
public function testPassInvalidInput(): void {
|
||||
$dir = $this->getDrupalRoot() . '/core/recipes/feedback_contact_form';
|
||||
$process = $this->applyRecipe($dir, 1, options: [
|
||||
'--input=feedback_contact_form.recipient=nobody',
|
||||
]);
|
||||
$this->assertStringContainsString('This value is not a valid email address.', $process->getErrorOutput());
|
||||
}
|
||||
|
||||
public function testDefaultInputValueFromConfig(): void {
|
||||
$this->config('system.site')
|
||||
->set('mail', 'goodbye@hello.net')
|
||||
->save();
|
||||
|
||||
$this->applyRecipe($this->getDrupalRoot() . '/core/recipes/feedback_contact_form');
|
||||
$this->assertSame(['goodbye@hello.net'], ContactForm::load('feedback')?->getRecipients());
|
||||
}
|
||||
|
||||
public function testListInputs(): void {
|
||||
$root = $this->getDrupalRoot();
|
||||
|
||||
$output = $this->applyRecipe($root . '/core/recipes/feedback_contact_form', command: 'recipe:info')->getOutput();
|
||||
$this->assertStringContainsString('feedback_contact_form.recipient', $output);
|
||||
$this->assertStringContainsString('The email address that should receive submissions from the feedback form.', $output);
|
||||
|
||||
$output = $this->applyRecipe($root . '/core/recipes/page_content_type', command: 'recipe:info')->getOutput();
|
||||
$this->assertStringContainsString('This recipe does not accept any input.', $output);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Recipe;
|
||||
|
||||
use Drupal\Component\Serialization\Yaml;
|
||||
use Drupal\Core\Recipe\Recipe;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Contains helper methods for interacting with recipes in functional tests.
|
||||
*/
|
||||
trait RecipeTestTrait {
|
||||
|
||||
/**
|
||||
* Creates a recipe in a temporary directory.
|
||||
*
|
||||
* @param string|array<mixed> $data
|
||||
* The contents of recipe.yml. If passed as an array, will be encoded to
|
||||
* YAML.
|
||||
* @param string|null $machine_name
|
||||
* The machine name for the recipe. Will be used as the directory name.
|
||||
*
|
||||
* @return \Drupal\Core\Recipe\Recipe
|
||||
* The recipe object.
|
||||
*/
|
||||
protected function createRecipe(string|array $data, ?string $machine_name = NULL): Recipe {
|
||||
if (is_array($data)) {
|
||||
$data = Yaml::encode($data);
|
||||
}
|
||||
$recipes_dir = $this->siteDirectory . '/recipes';
|
||||
if ($machine_name === NULL) {
|
||||
$dir = uniqid($recipes_dir . '/');
|
||||
}
|
||||
else {
|
||||
$dir = $recipes_dir . '/' . $machine_name;
|
||||
}
|
||||
mkdir($dir, recursive: TRUE);
|
||||
file_put_contents($dir . '/recipe.yml', $data);
|
||||
|
||||
return Recipe::createFromDirectory($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a recipe to the site.
|
||||
*
|
||||
* @param string $path
|
||||
* The path of the recipe to apply. Must be a directory.
|
||||
* @param int $expected_exit_code
|
||||
* The expected exit code of the `drupal recipe` process. Defaults to 0,
|
||||
* which indicates that no error occurred.
|
||||
* @param string[] $options
|
||||
* (optional) Additional options to pass to the `drupal recipe` command.
|
||||
* @param string $command
|
||||
* (optional) The name of the command to run. Defaults to `recipe`.
|
||||
*
|
||||
* @return \Symfony\Component\Process\Process
|
||||
* The `drupal recipe` command process, after having run.
|
||||
*/
|
||||
protected function applyRecipe(string $path, int $expected_exit_code = 0, array $options = [], string $command = 'recipe'): Process {
|
||||
assert($this instanceof BrowserTestBase);
|
||||
|
||||
$arguments = [
|
||||
(new PhpExecutableFinder())->find(),
|
||||
'core/scripts/drupal',
|
||||
$command,
|
||||
// Never apply recipes interactively.
|
||||
'--no-interaction',
|
||||
...$options,
|
||||
$path,
|
||||
];
|
||||
$process = (new Process($arguments))
|
||||
->setWorkingDirectory($this->getDrupalRoot())
|
||||
->setEnv([
|
||||
'DRUPAL_DEV_SITE_PATH' => $this->siteDirectory,
|
||||
// Ensure that the command boots Drupal into a state where it knows it's
|
||||
// a test site.
|
||||
// @see drupal_valid_test_ua()
|
||||
'HTTP_USER_AGENT' => drupal_generate_test_ua($this->databasePrefix),
|
||||
])
|
||||
->setTimeout(500);
|
||||
|
||||
$process->run();
|
||||
$this->assertSame($expected_exit_code, $process->getExitCode(), sprintf("Process exit code mismatch.\nExpected: %d\nActual: %d\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", $expected_exit_code, $process->getExitCode(), $process->getOutput(), $process->getErrorOutput()));
|
||||
// Applying a recipe:
|
||||
// - creates new checkpoints, hence the "state" service in the test runner
|
||||
// is outdated
|
||||
// - may install modules, which would cause the entire container in the test
|
||||
// runner to be outdated.
|
||||
// Hence the entire environment must be rebuilt for assertions to target the
|
||||
// actual post-recipe-application result.
|
||||
// @see \Drupal\Core\Config\Checkpoint\LinearHistory::__construct()
|
||||
$this->rebuildAll();
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alters an existing recipe.
|
||||
*
|
||||
* @param string $path
|
||||
* The recipe directory path.
|
||||
* @param callable $alter
|
||||
* A function that will receive the decoded contents of recipe.yml as an
|
||||
* array. This should returned a modified array to be written to recipe.yml.
|
||||
*/
|
||||
protected function alterRecipe(string $path, callable $alter): void {
|
||||
$file = $path . '/recipe.yml';
|
||||
$this->assertFileExists($file);
|
||||
$contents = file_get_contents($file);
|
||||
$contents = Yaml::decode($contents);
|
||||
$contents = $alter($contents);
|
||||
file_put_contents($file, Yaml::encode($contents));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Recipe;
|
||||
|
||||
use Drupal\Core\Config\Checkpoint\Checkpoint;
|
||||
use Drupal\Core\Datetime\Entity\DateFormat;
|
||||
use Drupal\Core\Recipe\Recipe;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* @group Recipe
|
||||
*/
|
||||
class RollbackTest extends BrowserTestBase {
|
||||
|
||||
use RecipeTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Disable strict config schema because this test explicitly makes the
|
||||
* recipe system save invalid config, to prove that it validates it after
|
||||
* the fact and raises an error.
|
||||
*/
|
||||
protected $strictConfigSchema = FALSE;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'system',
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
* @testWith ["invalid_config", "core.date_format.invalid"]
|
||||
* ["recipe_depend_on_invalid", "core.date_format.invalid"]
|
||||
* ["recipe_depend_on_invalid_config_and_valid_modules", "core.date_format.invalid"]
|
||||
*/
|
||||
public function testRollbackForInvalidConfig(string $recipe_fixture, string $expected_invalid_config_name): void {
|
||||
$expected_core_extension_modules = $this->config('core.extension')->get('module');
|
||||
|
||||
/** @var string $recipe_fixture */
|
||||
$recipe_fixture = realpath(__DIR__ . "/../../../../fixtures/recipes/$recipe_fixture");
|
||||
$process = $this->applyRecipe($recipe_fixture, 1);
|
||||
$this->assertStringContainsString("There were validation errors in $expected_invalid_config_name:", $process->getErrorOutput());
|
||||
$this->assertCheckpointsExist([
|
||||
"Backup before the '" . Recipe::createFromDirectory($recipe_fixture)->name . "' recipe.",
|
||||
]);
|
||||
|
||||
// @see invalid_config
|
||||
$date_formats = DateFormat::loadMultiple(['valid', 'invalid']);
|
||||
$this->assertEmpty($date_formats, "The recipe's imported config was not rolled back.");
|
||||
|
||||
// @see recipe_depend_on_invalid_config_and_valid_module
|
||||
$this->assertSame($expected_core_extension_modules, $this->config('core.extension')->get('module'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the current set of checkpoints matches the given labels.
|
||||
*
|
||||
* @param string[] $expected_labels
|
||||
* The labels of every checkpoint that is expected to exist currently, in
|
||||
* the expected order.
|
||||
*/
|
||||
private function assertCheckpointsExist(array $expected_labels): void {
|
||||
$checkpoints = \Drupal::service('config.checkpoints');
|
||||
$labels = array_map(fn (Checkpoint $c) => $c->label, iterator_to_array($checkpoints));
|
||||
$this->assertSame($expected_labels, array_values($labels));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Recipe;
|
||||
|
||||
use Drupal\contact\Entity\ContactForm;
|
||||
use Drupal\FunctionalTests\Installer\InstallerTestBase;
|
||||
use Drupal\shortcut\Entity\Shortcut;
|
||||
use Drupal\Tests\standard\Traits\StandardTestTrait;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\Yaml\Yaml as SymfonyYaml;
|
||||
|
||||
/**
|
||||
* Tests installing the Standard recipe via the installer.
|
||||
*
|
||||
* @group #slow
|
||||
* @group Recipe
|
||||
*/
|
||||
class StandardRecipeInstallTest extends InstallerTestBase {
|
||||
use StandardTestTrait {
|
||||
testStandard as doTestStandard;
|
||||
}
|
||||
use RecipeTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $profile = '';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
// Skip permissions hardening so we can write a services file later.
|
||||
$this->settings['settings']['skip_permissions_hardening'] = (object) [
|
||||
'value' => TRUE,
|
||||
'required' => TRUE,
|
||||
];
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function visitInstaller(): void {
|
||||
// Use a URL to install from a recipe.
|
||||
$this->drupalGet($GLOBALS['base_url'] . '/core/install.php?profile=&recipe=core/recipes/standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function testStandard(): void {
|
||||
if (!isset($this->rootUser->passRaw) && isset($this->rootUser->pass_raw)) {
|
||||
$this->rootUser->passRaw = $this->rootUser->pass_raw;
|
||||
}
|
||||
// These recipes provide functionality that is only optionally part of the
|
||||
// Standard profile, so we need to explicitly apply them.
|
||||
$this->applyRecipe('core/recipes/editorial_workflow');
|
||||
$this->applyRecipe('core/recipes/audio_media_type');
|
||||
$this->applyRecipe('core/recipes/document_media_type');
|
||||
$this->applyRecipe('core/recipes/image_media_type');
|
||||
$this->applyRecipe('core/recipes/local_video_media_type');
|
||||
$this->applyRecipe('core/recipes/remote_video_media_type');
|
||||
|
||||
// Add a Home link to the main menu as Standard expects "Main navigation"
|
||||
// block on the page.
|
||||
$this->drupalGet('admin/structure/menu/manage/main/add');
|
||||
$this->submitForm([
|
||||
'title[0][value]' => 'Home',
|
||||
'link[0][uri]' => '<front>',
|
||||
], 'Save');
|
||||
|
||||
// Standard expects to set the contact form's recipient email to the
|
||||
// system's email address, but our feedback_contact_form recipe hard-codes
|
||||
// it to another value.
|
||||
// @todo This can be removed after https://drupal.org/i/3303126, which
|
||||
// should make it possible for a recipe to reuse an already-set config
|
||||
// value.
|
||||
ContactForm::load('feedback')?->setRecipients(['simpletest@example.com'])
|
||||
->save();
|
||||
|
||||
// Standard ships two shortcuts; ensure they exist.
|
||||
$this->assertCount(2, Shortcut::loadMultiple());
|
||||
|
||||
// The installer logs you in.
|
||||
$this->drupalLogout();
|
||||
|
||||
$this->doTestStandard();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUpProfile(): void {
|
||||
// Noop. This form is skipped due the parameters set on the URL.
|
||||
}
|
||||
|
||||
protected function installDefaultThemeFromClassProperty(ContainerInterface $container): void {
|
||||
// In this context a default theme makes no sense.
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function installResponsiveImage(): void {
|
||||
// Overrides StandardTest::installResponsiveImage() in order to use the
|
||||
// recipe.
|
||||
$this->applyRecipe('core/recipes/standard_responsive_images');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUpSite(): void {
|
||||
$services_file = DRUPAL_ROOT . '/' . $this->siteDirectory . '/services.yml';
|
||||
// $content = file_get_contents($services_file);
|
||||
|
||||
// Disable the super user access.
|
||||
$yaml = new SymfonyYaml();
|
||||
$services = [];
|
||||
$services['parameters']['security.enable_super_user'] = FALSE;
|
||||
file_put_contents($services_file, $yaml->dump($services));
|
||||
parent::setUpSite();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Recipe;
|
||||
|
||||
use Drupal\shortcut\Entity\Shortcut;
|
||||
use Drupal\Tests\standard\Functional\StandardTest;
|
||||
use Drupal\user\RoleInterface;
|
||||
|
||||
/**
|
||||
* Tests Standard recipe installation expectations.
|
||||
*
|
||||
* @group #slow
|
||||
* @group Recipe
|
||||
*/
|
||||
class StandardRecipeTest extends StandardTest {
|
||||
|
||||
use RecipeTestTrait;
|
||||
|
||||
/**
|
||||
* Tests Standard installation recipe.
|
||||
*/
|
||||
public function testStandard(): void {
|
||||
// Install some modules that Standard has optional integrations with.
|
||||
\Drupal::service('module_installer')->install(['media_library', 'content_moderation']);
|
||||
|
||||
// Export all the configuration so we can compare later.
|
||||
$this->copyConfig(\Drupal::service('config.storage'), \Drupal::service('config.storage.sync'));
|
||||
|
||||
// Set theme to stark and uninstall the other themes.
|
||||
$theme_installer = \Drupal::service('theme_installer');
|
||||
$theme_installer->install(['stark']);
|
||||
$this->config('system.theme')->set('admin', '')->set('default', 'stark')->save();
|
||||
$theme_installer->uninstall(['claro', 'olivero']);
|
||||
|
||||
// Determine which modules to uninstall.
|
||||
$uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]);
|
||||
foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) {
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entity_type);
|
||||
$storage->delete($storage->loadMultiple());
|
||||
}
|
||||
|
||||
// Uninstall all the modules including the Standard profile.
|
||||
\Drupal::service('module_installer')->uninstall($uninstall);
|
||||
|
||||
// Clean up entity displays before recipe import.
|
||||
foreach (['entity_form_display', 'entity_view_display'] as $entity_type) {
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entity_type);
|
||||
$storage->delete($storage->loadMultiple());
|
||||
}
|
||||
|
||||
// Clean up roles before recipe import.
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('user_role');
|
||||
$roles = $storage->loadMultiple();
|
||||
// Do not delete the administrator role. There would be no user with the
|
||||
// permissions to create content.
|
||||
unset($roles[RoleInterface::ANONYMOUS_ID], $roles[RoleInterface::AUTHENTICATED_ID], $roles['administrator']);
|
||||
$storage->delete($roles);
|
||||
|
||||
$this->applyRecipe('core/recipes/standard');
|
||||
// These recipes provide functionality that is only optionally part of the
|
||||
// Standard profile, so we need to explicitly apply them.
|
||||
$this->applyRecipe('core/recipes/editorial_workflow');
|
||||
$this->applyRecipe('core/recipes/audio_media_type');
|
||||
$this->applyRecipe('core/recipes/document_media_type');
|
||||
$this->applyRecipe('core/recipes/image_media_type');
|
||||
$this->applyRecipe('core/recipes/local_video_media_type');
|
||||
$this->applyRecipe('core/recipes/remote_video_media_type');
|
||||
|
||||
// Remove the theme we had to install.
|
||||
\Drupal::service('theme_installer')->uninstall(['stark']);
|
||||
|
||||
// Add a Home link to the main menu as Standard expects "Main navigation"
|
||||
// block on the page.
|
||||
$this->drupalLogin($this->rootUser);
|
||||
$this->drupalGet('admin/structure/menu/manage/main/add');
|
||||
$this->submitForm([
|
||||
'title[0][value]' => 'Home',
|
||||
'link[0][uri]' => '<front>',
|
||||
], 'Save');
|
||||
|
||||
// Update sync directory config to have the same UUIDs so we can compare.
|
||||
/** @var \Drupal\Core\Config\StorageInterface $sync */
|
||||
$sync = \Drupal::service('config.storage.sync');
|
||||
/** @var \Drupal\Core\Config\StorageInterface $active */
|
||||
$active = \Drupal::service('config.storage');
|
||||
// @todo https://www.drupal.org/i/3439749 Determine if the the _core unset
|
||||
// is correct.
|
||||
foreach ($active->listAll() as $name) {
|
||||
/** @var mixed[] $active_data */
|
||||
$active_data = $active->read($name);
|
||||
if ($sync->exists($name)) {
|
||||
/** @var mixed[] $sync_data */
|
||||
$sync_data = $sync->read($name);
|
||||
if (isset($sync_data['uuid'])) {
|
||||
$sync_data['uuid'] = $active_data['uuid'];
|
||||
}
|
||||
if (isset($sync_data['_core'])) {
|
||||
unset($sync_data['_core']);
|
||||
}
|
||||
/** @var array $sync_data */
|
||||
$sync->write($name, $sync_data);
|
||||
}
|
||||
if (isset($active_data['_core'])) {
|
||||
unset($active_data['_core']);
|
||||
$active->write($name, $active_data);
|
||||
}
|
||||
// @todo Remove this once https://drupal.org/i/3427564 lands.
|
||||
if ($name === 'node.settings') {
|
||||
unset($active_data['langcode']);
|
||||
$active->write($name, $active_data);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have truly rebuilt the standard profile using recipes.
|
||||
// Uncomment the code below to see the differences in a single file.
|
||||
// phpcs:ignore Drupal.Files.LineLength
|
||||
// $this->assertSame($sync->read('node.settings'), $active->read('node.settings'));
|
||||
$comparer = $this->configImporter()->getStorageComparer();
|
||||
$expected_list = $comparer->getEmptyChangelist();
|
||||
// We expect core.extension to be different because standard is no longer
|
||||
// installed.
|
||||
$expected_list['update'] = ['core.extension'];
|
||||
$this->assertSame($expected_list, $comparer->getChangelist());
|
||||
|
||||
// Standard ships two shortcuts; ensure they exist.
|
||||
$this->assertCount(2, Shortcut::loadMultiple());
|
||||
|
||||
parent::testStandard();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function installResponsiveImage(): void {
|
||||
// Overrides StandardTest::installResponsiveImage() in order to use the
|
||||
// recipe.
|
||||
$this->applyRecipe('core/recipes/standard_responsive_images');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Test;
|
||||
|
||||
use Drupal\entity_test\Entity\EntityTest;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests batch operations during tests execution.
|
||||
*
|
||||
* This demonstrates that a batch will be successfully executed during module
|
||||
* installation when running tests.
|
||||
*
|
||||
* @group Test
|
||||
* @group FunctionalTestSetupTrait
|
||||
*/
|
||||
class ModuleInstallBatchTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['test_batch_test', 'entity_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests loading entities created in a batch in test_batch_test_install().
|
||||
*/
|
||||
public function testLoadingEntitiesCreatedInBatch(): void {
|
||||
foreach ([1, 2] as $id) {
|
||||
$this->assertNotNull(EntityTest::load($id), 'Successfully loaded entity ' . $id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Core\Test;
|
||||
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests Drupal's extension to manage code deprecation.
|
||||
*
|
||||
* @group Test
|
||||
* @group legacy
|
||||
*/
|
||||
class PhpUnitBridgeTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['deprecation_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* Tests deprecation message from deprecation_test_function().
|
||||
*/
|
||||
public function testSilencedError(): void {
|
||||
$this->expectDeprecation('This is the deprecation message for deprecation_test_function().');
|
||||
$this->assertEquals('known_return_value', deprecation_test_function());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests deprecation message from deprecated route.
|
||||
*/
|
||||
public function testErrorOnSiteUnderTest(): void {
|
||||
$this->expectDeprecation('This is the deprecation message for deprecation_test_function().');
|
||||
$this->drupalGet(Url::fromRoute('deprecation_test.route'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Datetime;
|
||||
|
||||
use Drupal\Core\Entity\Entity\EntityFormDisplay;
|
||||
use Drupal\Core\Entity\Entity\EntityViewDisplay;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the functionality of TimestampAgoFormatter core field formatter.
|
||||
*
|
||||
* @group field
|
||||
*/
|
||||
class TimestampAgoFormatterTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* An array of display options to pass to entity_get_display().
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $displayOptions;
|
||||
|
||||
/**
|
||||
* A field storage to use in this test class.
|
||||
*
|
||||
* @var \Drupal\field\Entity\FieldStorageConfig
|
||||
*/
|
||||
protected $fieldStorage;
|
||||
|
||||
/**
|
||||
* The field used in this test class.
|
||||
*
|
||||
* @var \Drupal\field\Entity\FieldConfig
|
||||
*/
|
||||
protected $field;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test', 'field_ui'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$web_user = $this->drupalCreateUser([
|
||||
'access administration pages',
|
||||
'view test entity',
|
||||
'administer entity_test content',
|
||||
'administer entity_test fields',
|
||||
'administer entity_test display',
|
||||
'administer entity_test form display',
|
||||
'view the administration theme',
|
||||
]);
|
||||
$this->drupalLogin($web_user);
|
||||
|
||||
$field_name = 'field_timestamp';
|
||||
$type = 'timestamp';
|
||||
$widget_type = 'datetime_timestamp';
|
||||
$formatter_type = 'timestamp_ago';
|
||||
|
||||
$this->fieldStorage = FieldStorageConfig::create([
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => 'entity_test',
|
||||
'type' => $type,
|
||||
]);
|
||||
$this->fieldStorage->save();
|
||||
$this->field = FieldConfig::create([
|
||||
'field_storage' => $this->fieldStorage,
|
||||
'bundle' => 'entity_test',
|
||||
'required' => TRUE,
|
||||
]);
|
||||
$this->field->save();
|
||||
|
||||
EntityFormDisplay::load('entity_test.entity_test.default')
|
||||
->setComponent($field_name, ['type' => $widget_type])
|
||||
->save();
|
||||
|
||||
$this->displayOptions = [
|
||||
'type' => $formatter_type,
|
||||
'label' => 'hidden',
|
||||
];
|
||||
|
||||
EntityViewDisplay::create([
|
||||
'targetEntityType' => $this->field->getTargetEntityTypeId(),
|
||||
'bundle' => $this->field->getTargetBundle(),
|
||||
'mode' => 'full',
|
||||
'status' => TRUE,
|
||||
])->setComponent($field_name, $this->displayOptions)
|
||||
->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the formatter settings.
|
||||
*/
|
||||
public function testSettings(): void {
|
||||
$this->drupalGet('entity_test/structure/entity_test/display');
|
||||
|
||||
$edit = [
|
||||
'fields[field_timestamp][region]' => 'content',
|
||||
'fields[field_timestamp][type]' => 'timestamp_ago',
|
||||
];
|
||||
$this->submitForm($edit, 'Save');
|
||||
|
||||
$this->submitForm([], 'field_timestamp_settings_edit');
|
||||
$edit = [
|
||||
'fields[field_timestamp][settings_edit_form][settings][future_format]' => 'ends in @interval',
|
||||
'fields[field_timestamp][settings_edit_form][settings][past_format]' => 'started @interval ago',
|
||||
'fields[field_timestamp][settings_edit_form][settings][granularity]' => 1,
|
||||
];
|
||||
$this->submitForm($edit, 'Update');
|
||||
$this->submitForm([], 'Save');
|
||||
|
||||
$this->assertSession()->pageTextContains('ends in 1 year');
|
||||
$this->assertSession()->pageTextContains('started 1 year ago');
|
||||
}
|
||||
|
||||
}
|
||||
182
web/core/tests/Drupal/FunctionalTests/Datetime/TimestampTest.php
Normal file
182
web/core/tests/Drupal/FunctionalTests/Datetime/TimestampTest.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Datetime;
|
||||
|
||||
use Drupal\Core\Datetime\DrupalDateTime;
|
||||
use Drupal\Core\Datetime\Entity\DateFormat;
|
||||
use Drupal\Core\Entity\Entity\EntityFormDisplay;
|
||||
use Drupal\Core\Entity\Entity\EntityViewDisplay;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the functionality of Timestamp core field UI.
|
||||
*
|
||||
* @group field
|
||||
*/
|
||||
class TimestampTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* An array of display options.
|
||||
*
|
||||
* These options are passed to
|
||||
* EntityDisplayRepositoryInterface::getViewDisplay().
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $displayOptions;
|
||||
|
||||
/**
|
||||
* A field storage to use in this test class.
|
||||
*
|
||||
* @var \Drupal\field\Entity\FieldStorageConfig
|
||||
*/
|
||||
protected $fieldStorage;
|
||||
|
||||
/**
|
||||
* The field used in this test class.
|
||||
*
|
||||
* @var \Drupal\field\Entity\FieldConfig
|
||||
*/
|
||||
protected $field;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['node', 'entity_test', 'field_ui'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$web_user = $this->drupalCreateUser([
|
||||
'access content',
|
||||
'view test entity',
|
||||
'administer entity_test content',
|
||||
'administer entity_test form display',
|
||||
'administer content types',
|
||||
'administer node fields',
|
||||
]);
|
||||
|
||||
$this->drupalLogin($web_user);
|
||||
$field_name = 'field_timestamp';
|
||||
$type = 'timestamp';
|
||||
$widget_type = 'datetime_timestamp';
|
||||
$formatter_type = 'timestamp';
|
||||
|
||||
$this->fieldStorage = FieldStorageConfig::create([
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => 'entity_test',
|
||||
'type' => $type,
|
||||
]);
|
||||
$this->fieldStorage->save();
|
||||
$this->field = FieldConfig::create([
|
||||
'field_storage' => $this->fieldStorage,
|
||||
'bundle' => 'entity_test',
|
||||
'required' => TRUE,
|
||||
'description' => 'Description for timestamp field.',
|
||||
]);
|
||||
$this->field->save();
|
||||
|
||||
EntityFormDisplay::load('entity_test.entity_test.default')
|
||||
->setComponent($field_name, ['type' => $widget_type])
|
||||
->save();
|
||||
|
||||
$this->displayOptions = [
|
||||
'type' => $formatter_type,
|
||||
'label' => 'hidden',
|
||||
];
|
||||
|
||||
EntityViewDisplay::create([
|
||||
'targetEntityType' => $this->field->getTargetEntityTypeId(),
|
||||
'bundle' => $this->field->getTargetBundle(),
|
||||
'mode' => 'full',
|
||||
'status' => TRUE,
|
||||
])->setComponent($field_name, $this->displayOptions)
|
||||
->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the "datetime_timestamp" widget.
|
||||
*/
|
||||
public function testWidget(): void {
|
||||
// Build up a date in the UTC timezone.
|
||||
$value = '2012-12-31 00:00:00';
|
||||
$date = new DrupalDateTime($value, 'UTC');
|
||||
|
||||
// Update the timezone to the system default.
|
||||
$date->setTimezone(timezone_open(date_default_timezone_get()));
|
||||
|
||||
// Display creation form.
|
||||
$this->drupalGet('entity_test/add');
|
||||
|
||||
// Make sure the field description is properly displayed.
|
||||
$this->assertSession()->pageTextContains('Description for timestamp field.');
|
||||
|
||||
// Make sure the "datetime_timestamp" widget is on the page.
|
||||
$this->assertSession()->elementsCount('xpath', '//div[contains(@class, "field--widget-datetime-timestamp") and @id="edit-field-timestamp-wrapper"]', 1);
|
||||
|
||||
// Look for the widget elements and make sure they are empty.
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][date]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][date]', '');
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][time]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][time]', '');
|
||||
|
||||
// Submit the date.
|
||||
$date_format = DateFormat::load('html_date')->getPattern();
|
||||
$time_format = DateFormat::load('html_time')->getPattern();
|
||||
|
||||
$edit = [
|
||||
'field_timestamp[0][value][date]' => $date->format($date_format),
|
||||
'field_timestamp[0][value][time]' => $date->format($time_format),
|
||||
];
|
||||
$this->submitForm($edit, 'Save');
|
||||
|
||||
// Make sure the submitted date is set as the default in the widget.
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][date]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][date]', $date->format($date_format));
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][time]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][time]', $date->format($time_format));
|
||||
|
||||
// Make sure the entity was saved.
|
||||
preg_match('|entity_test/manage/(\d+)|', $this->getSession()->getCurrentUrl(), $match);
|
||||
$id = $match[1];
|
||||
$this->assertSession()->pageTextContains(sprintf('entity_test %s has been created.', $id));
|
||||
|
||||
// Make sure the timestamp is output properly with the default formatter.
|
||||
$medium = DateFormat::load('medium')->getPattern();
|
||||
$this->drupalGet('entity_test/' . $id);
|
||||
$this->assertSession()->pageTextContains($date->format($medium));
|
||||
|
||||
// Build up a date in the UTC timezone.
|
||||
$value = '2024-01-16 00:00:00';
|
||||
$date = new DrupalDateTime($value, 'UTC');
|
||||
|
||||
// Set a default value for the field.
|
||||
$this->field->setDefaultValue($date->getTimestamp())->save();
|
||||
|
||||
// Update the timezone to the system default.
|
||||
$date->setTimezone(timezone_open(date_default_timezone_get()));
|
||||
|
||||
$this->drupalGet('entity_test/add');
|
||||
$date_format = DateFormat::load('html_date')->getPattern();
|
||||
$time_format = DateFormat::load('html_time')->getPattern();
|
||||
// Make sure the default field value is set as the default value in the
|
||||
// widget.
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][date]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][date]', $date->format($date_format));
|
||||
$this->assertSession()->fieldExists('field_timestamp[0][value][time]');
|
||||
$this->assertSession()->fieldValueEquals('field_timestamp[0][value][time]', $date->format($time_format));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\DefaultContent;
|
||||
|
||||
use ColinODell\PsrTestLogger\TestLogger;
|
||||
use Drupal\block_content\BlockContentInterface;
|
||||
use Drupal\block_content\Entity\BlockContentType;
|
||||
use Drupal\Component\Serialization\Yaml;
|
||||
use Drupal\Core\DefaultContent\PreImportEvent;
|
||||
use Drupal\Core\DefaultContent\Existing;
|
||||
use Drupal\Core\DefaultContent\Finder;
|
||||
use Drupal\Core\DefaultContent\Importer;
|
||||
use Drupal\Core\DefaultContent\ImportException;
|
||||
use Drupal\Core\DefaultContent\InvalidEntityException;
|
||||
use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
|
||||
use Drupal\Core\Entity\EntityRepositoryInterface;
|
||||
use Drupal\Core\File\FileExists;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\file\FileInterface;
|
||||
use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
|
||||
use Drupal\language\Entity\ConfigurableLanguage;
|
||||
use Drupal\language\Entity\ContentLanguageSettings;
|
||||
use Drupal\layout_builder\Section;
|
||||
use Drupal\media\MediaInterface;
|
||||
use Drupal\menu_link_content\MenuLinkContentInterface;
|
||||
use Drupal\node\NodeInterface;
|
||||
use Drupal\taxonomy\TermInterface;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait;
|
||||
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
|
||||
use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
|
||||
use Drupal\user\UserInterface;
|
||||
use Drupal\workspaces\Entity\Workspace;
|
||||
use Psr\Log\LogLevel;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @covers \Drupal\Core\DefaultContent\Importer
|
||||
* @group DefaultContent
|
||||
* @group Recipe
|
||||
* @group #slow
|
||||
*/
|
||||
class ContentImportTest extends BrowserTestBase {
|
||||
|
||||
use EntityReferenceFieldCreationTrait;
|
||||
use MediaTypeCreationTrait;
|
||||
use RecipeTestTrait;
|
||||
use TaxonomyTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'block_content',
|
||||
'content_translation',
|
||||
'entity_test',
|
||||
'layout_builder',
|
||||
'media',
|
||||
'menu_link_content',
|
||||
'node',
|
||||
'path',
|
||||
'path_alias',
|
||||
'system',
|
||||
'taxonomy',
|
||||
'user',
|
||||
'workspaces',
|
||||
];
|
||||
|
||||
/**
|
||||
* The directory with the source data.
|
||||
*/
|
||||
private readonly string $contentDir;
|
||||
|
||||
/**
|
||||
* The admin account.
|
||||
*/
|
||||
private UserInterface $adminAccount;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->adminAccount = $this->setUpCurrentUser(admin: TRUE);
|
||||
|
||||
BlockContentType::create(['id' => 'basic', 'label' => 'Basic'])->save();
|
||||
block_content_add_body_field('basic');
|
||||
|
||||
$this->createVocabulary(['vid' => 'tags']);
|
||||
$this->createMediaType('image', ['id' => 'image']);
|
||||
$this->drupalCreateContentType(['type' => 'page']);
|
||||
$this->drupalCreateContentType(['type' => 'article']);
|
||||
$this->createEntityReferenceField('node', 'article', 'field_tags', 'Tags', 'taxonomy_term');
|
||||
|
||||
// Create a field with custom serialization, so we can ensure that the
|
||||
// importer handles that properly.
|
||||
$field_storage = FieldStorageConfig::create([
|
||||
'entity_type' => 'taxonomy_term',
|
||||
'field_name' => 'field_serialized_stuff',
|
||||
'type' => 'serialized_property_item_test',
|
||||
]);
|
||||
$field_storage->save();
|
||||
FieldConfig::create([
|
||||
'field_storage' => $field_storage,
|
||||
'bundle' => 'tags',
|
||||
])->save();
|
||||
|
||||
ConfigurableLanguage::createFromLangcode('fr')->save();
|
||||
ContentLanguageSettings::create([
|
||||
'target_entity_type_id' => 'node',
|
||||
'target_bundle' => 'article',
|
||||
])
|
||||
->setThirdPartySetting('content_translation', 'enabled', TRUE)
|
||||
->save();
|
||||
|
||||
$this->contentDir = $this->getDrupalRoot() . '/core/tests/fixtures/default_content';
|
||||
\Drupal::service('file_system')->copy($this->contentDir . '/file/druplicon_copy.png', $this->publicFilesDirectory . '/druplicon_copy.png', FileExists::Error);
|
||||
|
||||
// Enable Layout Builder for the Page content type, with custom overrides.
|
||||
\Drupal::service(EntityDisplayRepositoryInterface::class)
|
||||
->getViewDisplay('node', 'page')
|
||||
->enableLayoutBuilder()
|
||||
->setOverridable()
|
||||
->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array<mixed>>
|
||||
* An array of test cases, each containing an existing entity handling mode.
|
||||
*/
|
||||
public static function providerImportEntityThatAlreadyExists(): array {
|
||||
return [
|
||||
[Existing::Error],
|
||||
[Existing::Skip],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider providerImportEntityThatAlreadyExists
|
||||
*/
|
||||
public function testImportEntityThatAlreadyExists(Existing $existing): void {
|
||||
$this->drupalCreateUser(values: ['uuid' => '94503467-be7f-406c-9795-fc25baa22203']);
|
||||
|
||||
if ($existing === Existing::Error) {
|
||||
$this->expectException(ImportException::class);
|
||||
$this->expectExceptionMessage('user 94503467-be7f-406c-9795-fc25baa22203 already exists.');
|
||||
}
|
||||
|
||||
$this->container->get(Importer::class)
|
||||
->importContent(new Finder($this->contentDir), $existing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests importing content directly, via the API.
|
||||
*/
|
||||
public function testDirectContentImport(): void {
|
||||
$logger = new TestLogger();
|
||||
|
||||
/** @var \Drupal\Core\DefaultContent\Importer $importer */
|
||||
$importer = $this->container->get(Importer::class);
|
||||
$importer->setLogger($logger);
|
||||
$importer->importContent(new Finder($this->contentDir));
|
||||
|
||||
$this->assertContentWasImported($this->adminAccount);
|
||||
// We should see a warning about importing a file entity associated with a
|
||||
// file that doesn't exist.
|
||||
$predicate = function (array $record): bool {
|
||||
return (
|
||||
$record['message'] === 'File entity %name was imported, but the associated file (@path) was not found.' &&
|
||||
$record['context']['%name'] === 'dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png' &&
|
||||
$record['context']['@path'] === $this->contentDir . '/file/dce9cdc3-d9fc-4d37-849d-105e913bb5ad.png'
|
||||
);
|
||||
};
|
||||
$this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::WARNING));
|
||||
|
||||
// Visit a page that is published in a non-live workspace; we should not be
|
||||
// able to see it, because we don't have permission.
|
||||
$node_in_workspace = $this->container->get(EntityRepositoryInterface::class)
|
||||
->loadEntityByUuid('node', '48475954-e878-439c-9d3d-226724a44269');
|
||||
$this->assertInstanceOf(NodeInterface::class, $node_in_workspace);
|
||||
$node_url = $node_in_workspace->toUrl();
|
||||
$this->drupalGet($node_url);
|
||||
$assert_session = $this->assertSession();
|
||||
$assert_session->statusCodeEquals(403);
|
||||
// If we log in with administrative privileges (i.e., we can look at any
|
||||
// workspace), we should be able to see it.
|
||||
$this->drupalLogin($this->adminAccount);
|
||||
$this->drupalGet($node_url);
|
||||
$assert_session->statusCodeEquals(200);
|
||||
$assert_session->pageTextContains($node_in_workspace->label());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests importing content directly, via the API, with a different user.
|
||||
*/
|
||||
public function testDirectContentImportWithDifferentUser(): void {
|
||||
$user = $this->createUser();
|
||||
$importer = $this->container->get(Importer::class);
|
||||
$importer->importContent(new Finder($this->contentDir), account: $user);
|
||||
$this->assertContentWasImported($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the importer validates entities before saving them.
|
||||
*/
|
||||
public function testEntityValidationIsTriggered(): void {
|
||||
$dir = uniqid('public://');
|
||||
mkdir($dir);
|
||||
|
||||
/** @var string $data */
|
||||
$data = file_get_contents($this->contentDir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml');
|
||||
$data = Yaml::decode($data);
|
||||
/** @var array{default: array{sticky: array<int, array{value: mixed}>}} $data */
|
||||
$data['default']['sticky'][0]['value'] = 'not a boolean!';
|
||||
file_put_contents($dir . '/invalid.yml', Yaml::encode($data));
|
||||
|
||||
$this->expectException(InvalidEntityException::class);
|
||||
$this->expectExceptionMessage("$dir/invalid.yml: sticky.0.value=This value should be of the correct primitive type.");
|
||||
$this->container->get(Importer::class)->importContent(new Finder($dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the default content was imported as expected.
|
||||
*
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The account that should own the imported content.
|
||||
*/
|
||||
private function assertContentWasImported(AccountInterface $account): void {
|
||||
/** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
|
||||
$entity_repository = $this->container->get(EntityRepositoryInterface::class);
|
||||
|
||||
$node = $entity_repository->loadEntityByUuid('node', 'e1714f23-70c0-4493-8e92-af1901771921');
|
||||
$this->assertInstanceOf(NodeInterface::class, $node);
|
||||
$this->assertSame('Crikey it works!', $node->body->value);
|
||||
$this->assertSame('article', $node->bundle());
|
||||
$this->assertSame('Test Article', $node->label());
|
||||
$tag = $node->field_tags->entity;
|
||||
$this->assertInstanceOf(TermInterface::class, $tag);
|
||||
$this->assertSame('Default Content', $tag->label());
|
||||
$this->assertSame('tags', $tag->bundle());
|
||||
$this->assertSame('550f86ad-aa11-4047-953f-636d42889f85', $tag->uuid());
|
||||
// The tag carries a field with serialized data, so ensure it came through
|
||||
// properly.
|
||||
$this->assertSame('a:2:{i:0;s:2:"Hi";i:1;s:6:"there!";}', $tag->field_serialized_stuff->value);
|
||||
$this->assertSame('94503467-be7f-406c-9795-fc25baa22203', $node->getOwner()->uuid());
|
||||
// The node's URL should use the path alias shipped with the recipe.
|
||||
$node_url = $node->toUrl()->toString();
|
||||
$this->assertSame(Url::fromUserInput('/test-article')->toString(), $node_url);
|
||||
|
||||
$media = $entity_repository->loadEntityByUuid('media', '344b943c-b231-4d73-9669-0b0a2be12aa5');
|
||||
$this->assertInstanceOf(MediaInterface::class, $media);
|
||||
$this->assertSame('image', $media->bundle());
|
||||
$this->assertSame('druplicon.png', $media->label());
|
||||
$file = $media->field_media_image->entity;
|
||||
$this->assertInstanceOf(FileInterface::class, $file);
|
||||
$this->assertSame('druplicon.png', $file->getFilename());
|
||||
$this->assertSame('d8404562-efcc-40e3-869e-40132d53fe0b', $file->uuid());
|
||||
|
||||
// Another file entity referencing an existing file but already in use by
|
||||
// another entity, should be imported.
|
||||
$same_file_different_entity = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f');
|
||||
$this->assertInstanceOf(FileInterface::class, $same_file_different_entity);
|
||||
$this->assertSame('druplicon-duplicate.png', $same_file_different_entity->getFilename());
|
||||
$this->assertStringEndsWith('/druplicon_0.png', (string) $same_file_different_entity->getFileUri());
|
||||
|
||||
// Another file entity that references a file with the same name as, but
|
||||
// different contents than, an existing file, should be imported and the
|
||||
// file should be renamed.
|
||||
$different_file = $entity_repository->loadEntityByUuid('file', 'a6b79928-838f-44bd-a8f0-44c2fff9e4cc');
|
||||
$this->assertInstanceOf(FileInterface::class, $different_file);
|
||||
$this->assertSame('druplicon-different.png', $different_file->getFilename());
|
||||
$this->assertStringEndsWith('/druplicon_1.png', (string) $different_file->getFileUri());
|
||||
|
||||
// Another file entity referencing an existing file but one that is not in
|
||||
// use by another entity, should be imported but use the existing file.
|
||||
$different_file = $entity_repository->loadEntityByUuid('file', '7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d');
|
||||
$this->assertInstanceOf(FileInterface::class, $different_file);
|
||||
$this->assertSame('druplicon_copy.png', $different_file->getFilename());
|
||||
$this->assertStringEndsWith('/druplicon_copy.png', (string) $different_file->getFileUri());
|
||||
|
||||
// Our node should have a menu link, and it should use the path alias we
|
||||
// included with the recipe.
|
||||
$menu_link = $entity_repository->loadEntityByUuid('menu_link_content', '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b');
|
||||
$this->assertInstanceOf(MenuLinkContentInterface::class, $menu_link);
|
||||
$this->assertSame($menu_link->getUrlObject()->toString(), $node_url);
|
||||
$this->assertSame('main', $menu_link->getMenuName());
|
||||
|
||||
$block_content = $entity_repository->loadEntityByUuid('block_content', 'd9b72b2f-a5ea-4a3f-b10c-28deb7b3b7bf');
|
||||
$this->assertInstanceOf(BlockContentInterface::class, $block_content);
|
||||
$this->assertSame('basic', $block_content->bundle());
|
||||
$this->assertSame('Useful Info', $block_content->label());
|
||||
$this->assertSame("I'd love to put some useful info here.", $block_content->body->value);
|
||||
|
||||
// A node with a non-existent owner should be reassigned to the current
|
||||
// user or the user provided to the importer.
|
||||
$node = $entity_repository->loadEntityByUuid('node', '7f1dd75a-0be2-4d3b-be5d-9d1a868b9267');
|
||||
$this->assertInstanceOf(NodeInterface::class, $node);
|
||||
$this->assertSame($account->id(), $node->getOwner()->id());
|
||||
|
||||
// Ensure a node with a translation is imported properly.
|
||||
$node = $entity_repository->loadEntityByUuid('node', '2d3581c3-92c7-4600-8991-a0d4b3741198');
|
||||
$this->assertInstanceOf(NodeInterface::class, $node);
|
||||
$translation = $node->getTranslation('fr');
|
||||
$this->assertSame('Perdu en traduction', $translation->label());
|
||||
$this->assertSame("Içi c'est la version français.", $translation->body->value);
|
||||
|
||||
// Layout data should be imported.
|
||||
$node = $entity_repository->loadEntityByUuid('node', '32650de8-9edd-48dc-80b8-8bda180ebbac');
|
||||
$this->assertInstanceOf(NodeInterface::class, $node);
|
||||
$section = $node->layout_builder__layout[0]->section;
|
||||
$this->assertInstanceOf(Section::class, $section);
|
||||
$this->assertCount(2, $section->getComponents());
|
||||
$this->assertSame('system_powered_by_block', $section->getComponent('03b45f14-cf74-469a-8398-edf3383ce7fa')->getPluginId());
|
||||
|
||||
// Workspaces should have been imported with their parent references intact.
|
||||
$workspaces = Workspace::loadMultiple();
|
||||
$this->assertArrayHasKey('test_workspace', $workspaces);
|
||||
$this->assertSame('test_workspace', $workspaces['inner_test']?->parent->entity->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that the pre-import event allows skipping certain entities.
|
||||
*/
|
||||
public function testPreImportEvent(): void {
|
||||
$invalid_uuid_detected = FALSE;
|
||||
|
||||
$listener = function (PreImportEvent $event) use (&$invalid_uuid_detected): void {
|
||||
$event->skip('3434bd5a-d2cd-4f26-bf79-a7f6b951a21b', 'Decided not to!');
|
||||
try {
|
||||
$event->skip('not-a-thing');
|
||||
}
|
||||
catch (\InvalidArgumentException) {
|
||||
$invalid_uuid_detected = TRUE;
|
||||
}
|
||||
};
|
||||
\Drupal::service(EventDispatcherInterface::class)
|
||||
->addListener(PreImportEvent::class, $listener);
|
||||
|
||||
$finder = new Finder($this->contentDir);
|
||||
$this->assertSame('menu_link_content', $finder->data['3434bd5a-d2cd-4f26-bf79-a7f6b951a21b']['_meta']['entity_type']);
|
||||
|
||||
/** @var \Drupal\Core\DefaultContent\Importer $importer */
|
||||
$importer = \Drupal::service(Importer::class);
|
||||
$logger = new TestLogger();
|
||||
$importer->setLogger($logger);
|
||||
$importer->importContent($finder, Existing::Error);
|
||||
|
||||
// The entity we skipped should not be here, and the reason why should have
|
||||
// been logged.
|
||||
$menu_link = \Drupal::service(EntityRepositoryInterface::class)
|
||||
->loadEntityByUuid('menu_link_content', '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b');
|
||||
$this->assertNull($menu_link);
|
||||
$this->assertTrue($logger->hasInfo([
|
||||
'message' => 'Skipped importing @entity_type @uuid because: %reason',
|
||||
'context' => [
|
||||
'@entity_type' => 'menu_link_content',
|
||||
'@uuid' => '3434bd5a-d2cd-4f26-bf79-a7f6b951a21b',
|
||||
'%reason' => 'Decided not to!',
|
||||
],
|
||||
]));
|
||||
// We should have caught an exception for trying to skip an invalid UUID.
|
||||
$this->assertTrue($invalid_uuid_detected);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the correct mapping of user input on the correct field delta elements.
|
||||
*
|
||||
* @group Entity
|
||||
*/
|
||||
class ContentEntityFormCorrectUserInputMappingOnFieldDeltaElementsTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* The ID of the type of the entity under test.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $entityTypeId;
|
||||
|
||||
/**
|
||||
* The field name with multiple properties being test with the entity type.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldName;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$web_user = $this->drupalCreateUser(['administer entity_test content']);
|
||||
$this->drupalLogin($web_user);
|
||||
|
||||
// Create a field of field type "shape" with unlimited cardinality on the
|
||||
// entity type "entity_test".
|
||||
$this->entityTypeId = 'entity_test';
|
||||
$this->fieldName = 'shape';
|
||||
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => $this->fieldName,
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'type' => 'shape',
|
||||
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
|
||||
])
|
||||
->save();
|
||||
FieldConfig::create([
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'field_name' => $this->fieldName,
|
||||
'bundle' => $this->entityTypeId,
|
||||
'label' => 'Shape',
|
||||
'translatable' => FALSE,
|
||||
])
|
||||
->save();
|
||||
|
||||
\Drupal::service('entity_display.repository')
|
||||
->getFormDisplay($this->entityTypeId, $this->entityTypeId)
|
||||
->setComponent($this->fieldName, ['type' => 'shape_only_color_editable_widget'])
|
||||
->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the correct user input mapping on complex fields.
|
||||
*/
|
||||
public function testCorrectUserInputMappingOnComplexFields(): void {
|
||||
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
|
||||
$storage = $this->container->get('entity_type.manager')->getStorage($this->entityTypeId);
|
||||
|
||||
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
|
||||
$entity = $storage->create([
|
||||
$this->fieldName => [
|
||||
['shape' => 'rectangle', 'color' => 'green'],
|
||||
['shape' => 'circle', 'color' => 'blue'],
|
||||
],
|
||||
]);
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($this->entityTypeId . '/manage/' . $entity->id() . '/edit');
|
||||
|
||||
// Rearrange the field items.
|
||||
$edit = [
|
||||
"$this->fieldName[0][_weight]" => 0,
|
||||
"$this->fieldName[1][_weight]" => -1,
|
||||
];
|
||||
// Executing an ajax call is important before saving as it will trigger
|
||||
// form state caching and so if for any reasons the form is rebuilt with
|
||||
// the entity built based on the user submitted values with already
|
||||
// reordered field items then the correct mapping will break after the form
|
||||
// builder maps over the new form the user submitted values based on the
|
||||
// previous delta ordering.
|
||||
//
|
||||
// This is how currently the form building process works and this test
|
||||
// ensures the correct behavior no matter what changes would be made to the
|
||||
// form builder or the content entity forms.
|
||||
$this->submitForm($edit, 'Add another item');
|
||||
$this->submitForm([], 'Save');
|
||||
|
||||
// Reload the entity.
|
||||
$entity = $storage->load($entity->id());
|
||||
|
||||
// Assert that after rearranging the field items the user input will be
|
||||
// mapped on the correct delta field items.
|
||||
$this->assertEquals([
|
||||
['shape' => 'circle', 'color' => 'blue'],
|
||||
['shape' => 'rectangle', 'color' => 'green'],
|
||||
], $entity->get($this->fieldName)->getValue());
|
||||
|
||||
$this->drupalGet($this->entityTypeId . '/manage/' . $entity->id() . '/edit');
|
||||
|
||||
// Delete one of the field items and ensure that the user input is mapped on
|
||||
// the correct delta field items.
|
||||
$edit = [
|
||||
"$this->fieldName[0][_weight]" => 0,
|
||||
"$this->fieldName[1][_weight]" => -1,
|
||||
];
|
||||
$this->submitForm($edit, "{$this->fieldName}_0_remove_button");
|
||||
$this->submitForm([], 'Save');
|
||||
|
||||
$entity = $storage->load($entity->id());
|
||||
$this->assertEquals([
|
||||
['shape' => 'rectangle', 'color' => 'green'],
|
||||
], $entity->get($this->fieldName)->getValue());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\field\Entity\FieldConfig;
|
||||
use Drupal\field\Entity\FieldStorageConfig;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* Tests field validation filtering on content entity forms.
|
||||
*
|
||||
* @group Entity
|
||||
*/
|
||||
class ContentEntityFormFieldValidationFilteringTest extends BrowserTestBase {
|
||||
|
||||
use TestFileCreationTrait;
|
||||
|
||||
/**
|
||||
* The ID of the type of the entity under test.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $entityTypeId;
|
||||
|
||||
/**
|
||||
* The single-valued field name being tested with the entity type.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldNameSingle;
|
||||
|
||||
/**
|
||||
* The multi-valued field name being tested with the entity type.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldNameMultiple;
|
||||
|
||||
/**
|
||||
* The name of the file field being tested with the entity type.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldNameFile;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test', 'field_test', 'file', 'image'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$web_user = $this->drupalCreateUser(['administer entity_test content']);
|
||||
$this->drupalLogin($web_user);
|
||||
|
||||
// Create two fields of field type "test_field", one with single cardinality
|
||||
// and one with unlimited cardinality on the entity type "entity_test". It
|
||||
// is important to use this field type because its default widget has a
|
||||
// custom \Drupal\Core\Field\WidgetInterface::errorElement() implementation.
|
||||
$this->entityTypeId = 'entity_test';
|
||||
$this->fieldNameSingle = 'test_single';
|
||||
$this->fieldNameMultiple = 'test_multiple';
|
||||
$this->fieldNameFile = 'test_file';
|
||||
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => $this->fieldNameSingle,
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'type' => 'test_field',
|
||||
'cardinality' => 1,
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'field_name' => $this->fieldNameSingle,
|
||||
'bundle' => $this->entityTypeId,
|
||||
'label' => 'Test single',
|
||||
'required' => TRUE,
|
||||
'translatable' => FALSE,
|
||||
])->save();
|
||||
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => $this->fieldNameMultiple,
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'type' => 'test_field',
|
||||
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'field_name' => $this->fieldNameMultiple,
|
||||
'bundle' => $this->entityTypeId,
|
||||
'label' => 'Test multiple',
|
||||
'translatable' => FALSE,
|
||||
])->save();
|
||||
|
||||
// Also create a file field to test its '#limit_validation_errors'
|
||||
// implementation.
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => $this->fieldNameFile,
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'type' => 'file',
|
||||
'cardinality' => 1,
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'entity_type' => $this->entityTypeId,
|
||||
'field_name' => $this->fieldNameFile,
|
||||
'bundle' => $this->entityTypeId,
|
||||
'label' => 'Test file',
|
||||
'translatable' => FALSE,
|
||||
])->save();
|
||||
|
||||
$this->container->get('entity_display.repository')
|
||||
->getFormDisplay($this->entityTypeId, $this->entityTypeId, 'default')
|
||||
->setComponent($this->fieldNameSingle, ['type' => 'test_field_widget'])
|
||||
->setComponent($this->fieldNameMultiple, ['type' => 'test_field_widget'])
|
||||
->setComponent($this->fieldNameFile, ['type' => 'file_generic'])
|
||||
->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests field widgets with #limit_validation_errors.
|
||||
*/
|
||||
public function testFieldWidgetsWithLimitedValidationErrors(): void {
|
||||
$assert_session = $this->assertSession();
|
||||
$this->drupalGet($this->entityTypeId . '/add');
|
||||
|
||||
// The 'Test multiple' field is the only multi-valued field in the form, so
|
||||
// try to add a new item for it. This tests the '#limit_validation_errors'
|
||||
// property set by \Drupal\Core\Field\WidgetBase::formMultipleElements().
|
||||
$assert_session->elementsCount('css', 'div#edit-test-multiple-wrapper div.js-form-type-textfield input', 1);
|
||||
$this->submitForm([], 'Add another item');
|
||||
$assert_session->elementsCount('css', 'div#edit-test-multiple-wrapper div.js-form-type-textfield input', 2);
|
||||
|
||||
// Now try to upload a file. This tests the '#limit_validation_errors'
|
||||
// property set by
|
||||
// \Drupal\file\Plugin\Field\FieldWidget\FileWidget::process().
|
||||
$text_file = current($this->getTestFiles('text'));
|
||||
$edit = [
|
||||
'files[test_file_0]' => \Drupal::service('file_system')->realpath($text_file->uri),
|
||||
];
|
||||
$assert_session->elementNotExists('css', 'input#edit-test-file-0-remove-button');
|
||||
$this->submitForm($edit, 'Upload');
|
||||
$assert_session->elementExists('css', 'input#edit-test-file-0-remove-button');
|
||||
|
||||
// Make the 'Test multiple' field required and check that adding another
|
||||
// item does not throw a validation error.
|
||||
$field_config = FieldConfig::loadByName($this->entityTypeId, $this->entityTypeId, $this->fieldNameMultiple);
|
||||
$field_config->setRequired(TRUE);
|
||||
$field_config->save();
|
||||
|
||||
$this->drupalGet($this->entityTypeId . '/add');
|
||||
$this->submitForm([], 'Add another item');
|
||||
$assert_session->pageTextNotContains('Test multiple (value 1) field is required.');
|
||||
|
||||
// Check that saving the form without entering any value for the required
|
||||
// field still throws the proper validation errors.
|
||||
$this->submitForm([], 'Save');
|
||||
$assert_session->pageTextContains('Test single field is required.');
|
||||
$assert_session->pageTextContains('Test multiple (value 1) field is required.');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\entity_test\Entity\EntityTestBundle;
|
||||
use Drupal\entity_test\Entity\EntityTestMulRevPub;
|
||||
use Drupal\entity_test\Entity\EntityTestRev;
|
||||
use Drupal\language\Entity\ConfigurableLanguage;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests the delete multiple confirmation form.
|
||||
*
|
||||
* @group Entity
|
||||
* @runTestsInSeparateProcesses
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
class DeleteMultipleFormTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* The current user.
|
||||
*
|
||||
* @var \Drupal\Core\Session\AccountInterface
|
||||
*/
|
||||
protected $account;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['entity_test', 'user', 'language'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
EntityTestBundle::create([
|
||||
'id' => 'default',
|
||||
'label' => 'Default',
|
||||
])->save();
|
||||
$this->account = $this->drupalCreateUser([
|
||||
'administer entity_test content',
|
||||
]);
|
||||
$this->drupalLogin($this->account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the delete form for translatable entities.
|
||||
*/
|
||||
public function testTranslatableEntities(): void {
|
||||
ConfigurableLanguage::createFromLangcode('es')->save();
|
||||
ConfigurableLanguage::createFromLangcode('fr')->save();
|
||||
|
||||
$selection = [];
|
||||
|
||||
$entity1 = EntityTestMulRevPub::create(['type' => 'default', 'name' => 'entity1']);
|
||||
$entity1->addTranslation('es', ['name' => 'entity1 spanish']);
|
||||
$entity1->addTranslation('fr', ['name' => 'entity1 french']);
|
||||
$entity1->save();
|
||||
$selection[$entity1->id()]['en'] = 'en';
|
||||
|
||||
$entity2 = EntityTestMulRevPub::create(['type' => 'default', 'name' => 'entity2']);
|
||||
$entity2->addTranslation('es', ['name' => 'entity2 spanish']);
|
||||
$entity2->addTranslation('fr', ['name' => 'entity2 french']);
|
||||
$entity2->save();
|
||||
$selection[$entity2->id()]['es'] = 'es';
|
||||
$selection[$entity2->id()]['fr'] = 'fr';
|
||||
|
||||
$entity3 = EntityTestMulRevPub::create(['type' => 'default', 'name' => 'entity3']);
|
||||
$entity3->addTranslation('es', ['name' => 'entity3 spanish']);
|
||||
$entity3->addTranslation('fr', ['name' => 'entity3 french']);
|
||||
$entity3->save();
|
||||
$selection[$entity3->id()]['fr'] = 'fr';
|
||||
|
||||
// This entity will be inaccessible because of
|
||||
// Drupal\entity_test\EntityTestAccessControlHandler.
|
||||
$entity4 = EntityTestMulRevPub::create(['type' => 'default', 'name' => 'forbid_access']);
|
||||
$entity4->save();
|
||||
$selection[$entity4->id()]['en'] = 'en';
|
||||
|
||||
// Add the selection to the tempstore just like DeleteAction would.
|
||||
$tempstore = \Drupal::service('tempstore.private')->get('entity_delete_multiple_confirm');
|
||||
$tempstore->set($this->account->id() . ':entity_test_mulrevpub', $selection);
|
||||
|
||||
$this->drupalGet('/entity_test/delete');
|
||||
$assert = $this->assertSession();
|
||||
$assert->statusCodeEquals(200);
|
||||
$assert->elementTextContains('css', 'h1', 'Are you sure you want to delete these test entity - revisions, data table, and published interface entities?');
|
||||
$list_selector = '#entity-test-mulrevpub-delete-multiple-confirm-form > ul[data-drupal-selector="edit-entities"]';
|
||||
$assert->elementTextContains('css', $list_selector, 'entity1 (Original translation) - The following test entity - revisions, data table, and published interface translations will be deleted:');
|
||||
$assert->elementTextContains('css', $list_selector, 'entity2 spanish');
|
||||
$assert->elementTextContains('css', $list_selector, 'entity2 french');
|
||||
$assert->elementTextNotContains('css', $list_selector, 'entity3 spanish');
|
||||
$assert->elementTextContains('css', $list_selector, 'entity3 french');
|
||||
$delete_button = $this->getSession()->getPage()->findButton('Delete');
|
||||
$delete_button->click();
|
||||
$assert = $this->assertSession();
|
||||
$assert->addressEquals('/user/' . $this->account->id());
|
||||
$assert->responseContains('Deleted 6 items.');
|
||||
$assert->responseContains('1 item has not been deleted because you do not have the necessary permissions.');
|
||||
|
||||
\Drupal::entityTypeManager()->getStorage('entity_test_mulrevpub')->resetCache();
|
||||
$remaining_entities = EntityTestMulRevPub::loadMultiple([$entity1->id(), $entity2->id(), $entity3->id(), $entity4->id()]);
|
||||
$this->assertCount(3, $remaining_entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the delete form for untranslatable entities.
|
||||
*/
|
||||
public function testUntranslatableEntities(): void {
|
||||
$selection = [];
|
||||
|
||||
$entity1 = EntityTestRev::create(['type' => 'default', 'name' => 'entity1']);
|
||||
$entity1->save();
|
||||
$selection[$entity1->id()]['en'] = 'en';
|
||||
|
||||
$entity2 = EntityTestRev::create(['type' => 'default', 'name' => 'entity2']);
|
||||
$entity2->save();
|
||||
$selection[$entity2->id()]['en'] = 'en';
|
||||
|
||||
// This entity will be inaccessible because of
|
||||
// Drupal\entity_test\EntityTestAccessControlHandler.
|
||||
$entity3 = EntityTestRev::create(['type' => 'default', 'name' => 'forbid_access']);
|
||||
$entity3->save();
|
||||
$selection[$entity3->id()]['en'] = 'en';
|
||||
|
||||
// This entity will be inaccessible because of
|
||||
// Drupal\entity_test\EntityTestAccessControlHandler.
|
||||
$entity4 = EntityTestRev::create(['type' => 'default', 'name' => 'forbid_access']);
|
||||
$entity4->save();
|
||||
$selection[$entity4->id()]['en'] = 'en';
|
||||
|
||||
// Add the selection to the tempstore just like DeleteAction would.
|
||||
$tempstore = \Drupal::service('tempstore.private')->get('entity_delete_multiple_confirm');
|
||||
$tempstore->set($this->account->id() . ':entity_test_rev', $selection);
|
||||
|
||||
$this->drupalGet('/entity_test_rev/delete_multiple');
|
||||
$assert = $this->assertSession();
|
||||
$assert->statusCodeEquals(200);
|
||||
$assert->elementTextContains('css', 'h1', 'Are you sure you want to delete these test entity - revisions entities?');
|
||||
$list_selector = '#entity-test-rev-delete-multiple-confirm-form > ul[data-drupal-selector="edit-entities"]';
|
||||
$assert->elementTextContains('css', $list_selector, 'entity1');
|
||||
$assert->elementTextContains('css', $list_selector, 'entity2');
|
||||
$delete_button = $this->getSession()->getPage()->findButton('Delete');
|
||||
$delete_button->click();
|
||||
$assert = $this->assertSession();
|
||||
$assert->addressEquals('/user/' . $this->account->id());
|
||||
$assert->responseContains('Deleted 2 items.');
|
||||
$assert->responseContains('2 items have not been deleted because you do not have the necessary permissions.');
|
||||
|
||||
\Drupal::entityTypeManager()->getStorage('entity_test_mulrevpub')->resetCache();
|
||||
$remaining_entities = EntityTestRev::loadMultiple([$entity1->id(), $entity2->id(), $entity3->id(), $entity4->id()]);
|
||||
$this->assertCount(2, $remaining_entities);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\entity_test\Entity\EntityTestBundle;
|
||||
use Drupal\entity_test\Entity\EntityTestWithBundle;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
|
||||
|
||||
/**
|
||||
* Tests that bundle tags are invalidated when entities change.
|
||||
*
|
||||
* @group Entity
|
||||
*/
|
||||
class EntityBundleListCacheTest extends BrowserTestBase {
|
||||
|
||||
use AssertPageCacheContextsAndTagsTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['cache_test', 'entity_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
EntityTestBundle::create([
|
||||
'id' => 'bundle_a',
|
||||
'label' => 'Bundle A',
|
||||
])->save();
|
||||
EntityTestBundle::create([
|
||||
'id' => 'bundle_b',
|
||||
'label' => 'Bundle B',
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that tags are invalidated when an entity with that bundle changes.
|
||||
*/
|
||||
public function testBundleListingCache(): void {
|
||||
// Access to lists of test entities with each bundle.
|
||||
$bundle_a_url = Url::fromRoute('cache_test_list.bundle_tags', ['entity_type_id' => 'entity_test_with_bundle', 'bundle' => 'bundle_a']);
|
||||
$bundle_b_url = Url::fromRoute('cache_test_list.bundle_tags', ['entity_type_id' => 'entity_test_with_bundle', 'bundle' => 'bundle_b']);
|
||||
$this->drupalGet($bundle_a_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
|
||||
$this->assertCacheTags(['rendered', 'entity_test_with_bundle_list:bundle_a']);
|
||||
|
||||
$this->drupalGet($bundle_a_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT');
|
||||
$this->assertCacheTags(['rendered', 'entity_test_with_bundle_list:bundle_a']);
|
||||
$this->drupalGet($bundle_b_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
|
||||
$this->assertCacheTags(['rendered', 'entity_test_with_bundle_list:bundle_b']);
|
||||
$this->drupalGet($bundle_b_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT');
|
||||
$entity1 = EntityTestWithBundle::create(['type' => 'bundle_a', 'name' => 'entity1']);
|
||||
$entity1->save();
|
||||
// Check that tags are invalidated after creating an entity of the current
|
||||
// bundle.
|
||||
$this->drupalGet($bundle_a_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
|
||||
$this->drupalGet($bundle_a_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT');
|
||||
// Check that tags are not invalidated after creating an entity of a
|
||||
// different bundle than the current in the request.
|
||||
$this->drupalGet($bundle_b_url);
|
||||
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Component\Uuid\Uuid;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
|
||||
|
||||
/**
|
||||
* Tests that an entity with a UUID as ID can be managed.
|
||||
*
|
||||
* @group Entity
|
||||
*/
|
||||
class EntityUuidIdTest extends BrowserTestBase {
|
||||
|
||||
use ContentTranslationTestTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = ['block', 'content_translation', 'entity_test'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->createLanguageFromLangcode('af');
|
||||
$this->enableContentTranslation('entity_test_uuid_id', 'entity_test_uuid_id');
|
||||
$this->drupalPlaceBlock('page_title_block');
|
||||
$this->drupalPlaceBlock('local_tasks_block');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the user interface for the test entity.
|
||||
*/
|
||||
public function testUi(): void {
|
||||
$this->drupalLogin($this->createUser([
|
||||
'administer entity_test content',
|
||||
'create content translations',
|
||||
'translate entity_test_uuid_id',
|
||||
'view test entity',
|
||||
]));
|
||||
|
||||
// Test adding an entity.
|
||||
$this->drupalGet('/entity_test_uuid_id/add');
|
||||
$this->submitForm([
|
||||
'Name' => 'Test entity with UUID ID',
|
||||
], 'Save');
|
||||
$this->assertSession()->elementTextEquals('css', 'h1', 'Edit Test entity with UUID ID');
|
||||
$this->assertSession()->addressMatches('#^/entity_test_uuid_id/manage/' . Uuid::VALID_PATTERN . '/edit$#');
|
||||
|
||||
// Test translating an entity.
|
||||
$this->clickLink('Translate');
|
||||
$this->clickLink('Add');
|
||||
$this->submitForm([
|
||||
'Name' => 'Afrikaans translation of test entity with UUID ID',
|
||||
], 'Save');
|
||||
$this->assertSession()->elementTextEquals('css', 'h1', 'Afrikaans translation of test entity with UUID ID [Afrikaans translation]');
|
||||
$this->assertSession()->addressMatches('#^/af/entity_test_uuid_id/manage/' . Uuid::VALID_PATTERN . '/edit$#');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Entity\RevisionLogInterface;
|
||||
use Drupal\entity_test\Entity\EntityTestRev;
|
||||
use Drupal\entity_test\Entity\EntityTestRevPub;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests deleting a revision with revision delete form.
|
||||
*
|
||||
* @group Entity
|
||||
* @group #slow
|
||||
* @coversDefaultClass \Drupal\Core\Entity\Form\RevisionDeleteForm
|
||||
*/
|
||||
class RevisionDeleteFormTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'block',
|
||||
'entity_test',
|
||||
'entity_test_revlog',
|
||||
'dblog',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->drupalPlaceBlock('page_title_block');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests title by whether entity supports revision creation dates.
|
||||
*/
|
||||
public function testPageTitle(): void {
|
||||
foreach (static::providerPageTitle() as $cases) {
|
||||
[$entityTypeId, $expectedQuestion] = $cases;
|
||||
$this->doTestPageTitle($entityTypeId, $expectedQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests title by whether entity supports revision creation dates.
|
||||
*
|
||||
* @param string $entityTypeId
|
||||
* The entity type to test.
|
||||
* @param string $expectedQuestion
|
||||
* The expected question/page title.
|
||||
*
|
||||
* @covers ::getQuestion
|
||||
*/
|
||||
protected function doTestPageTitle(string $entityTypeId, string $expectedQuestion): void {
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
|
||||
|
||||
$entity = $storage->create([
|
||||
'type' => $entityTypeId,
|
||||
'name' => 'delete revision',
|
||||
]);
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$date = new \DateTime('11 January 2009 4:00:00pm');
|
||||
$entity->setRevisionCreationTime($date->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
// Create a new latest revision.
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
// Reload the entity.
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-delete-form'));
|
||||
$this->assertSession()->pageTextContains($expectedQuestion);
|
||||
$this->assertSession()->buttonExists('Delete');
|
||||
$this->assertSession()->linkExists('Cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testPageTitle.
|
||||
*/
|
||||
public static function providerPageTitle(): array {
|
||||
return [
|
||||
['entity_test_rev', 'Are you sure you want to delete the revision?'],
|
||||
['entity_test_revlog', 'Are you sure you want to delete the revision from Sun, 11 Jan 2009 - 16:00?'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot delete latest revision.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
public function testAccessDeleteLatestDefault(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create();
|
||||
$entity->setName('delete revision');
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('revision-delete-form'));
|
||||
$this->assertSession()->statusCodeEquals(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that revisions can and can't be deleted in various scenarios.
|
||||
*/
|
||||
public function testAccessDelete(): void {
|
||||
$this->testAccessDeleteLatestForwardRevision();
|
||||
$this->testAccessDeleteDefault();
|
||||
$this->testAccessDeleteNonLatest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that forward revision can be deleted.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessDeleteLatestForwardRevision(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRevPub $entity */
|
||||
$entity = EntityTestRevPub::create();
|
||||
$entity->setName('delete revision');
|
||||
$entity->save();
|
||||
|
||||
$entity->isDefaultRevision(TRUE);
|
||||
$entity->setPublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->isDefaultRevision(FALSE);
|
||||
$entity->setUnpublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('revision-delete-form'));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertTrue($entity->access('delete revision', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot delete default revision.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessDeleteDefault(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRevPub $entity */
|
||||
$entity = EntityTestRevPub::create();
|
||||
$entity->setName('delete revision');
|
||||
$entity->save();
|
||||
|
||||
$entity->isDefaultRevision(TRUE);
|
||||
$entity->setPublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
$entity->isDefaultRevision(FALSE);
|
||||
$entity->setUnpublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
// Reload the entity.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_revpub');
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRevPub $revision */
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
// Check default but not latest.
|
||||
$this->assertTrue($revision->isDefaultRevision());
|
||||
$this->assertFalse($revision->isLatestRevision());
|
||||
$this->drupalGet($revision->toUrl('revision-delete-form'));
|
||||
$this->assertSession()->statusCodeEquals(403);
|
||||
$this->assertFalse($revision->access('delete revision', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test can delete non-latest revision.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessDeleteNonLatest(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create();
|
||||
$entity->setName('delete revision');
|
||||
$entity->save();
|
||||
$entity->isDefaultRevision();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
// Reload the entity.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_rev');
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-delete-form'));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertTrue($revision->access('delete revision', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests revision deletion form.
|
||||
*/
|
||||
public function testSubmitForm(): void {
|
||||
foreach (static::providerSubmitForm() as $case) {
|
||||
[$permissions, $entityTypeId, $entityLabel, $totalRevisions, $expectedLog, $expectedMessage, $expectedDestination] = $case;
|
||||
$this->doTestSubmitForm($permissions, $entityTypeId, $entityLabel, $totalRevisions, $expectedLog, $expectedMessage, $expectedDestination);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests revision deletion, and expected response after deletion.
|
||||
*
|
||||
* @param array $permissions
|
||||
* If not empty, a user will be created and logged in with these
|
||||
* permissions.
|
||||
* @param string $entityTypeId
|
||||
* The entity type to test.
|
||||
* @param string $entityLabel
|
||||
* The entity label, which corresponds to access grants.
|
||||
* @param int $totalRevisions
|
||||
* Total number of revisions to create.
|
||||
* @param string $expectedLog
|
||||
* Expected log.
|
||||
* @param string $expectedMessage
|
||||
* Expected messenger message.
|
||||
* @param string|int $expectedDestination
|
||||
* Expected destination after deletion.
|
||||
*
|
||||
* @covers ::submitForm
|
||||
*/
|
||||
protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, array $expectedLog, string $expectedMessage, $expectedDestination): void {
|
||||
if (count($permissions) > 0) {
|
||||
$this->drupalLogin($this->createUser($permissions));
|
||||
}
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
|
||||
|
||||
$entity = $storage->create([
|
||||
'type' => $entityTypeId,
|
||||
'name' => $entityLabel,
|
||||
]);
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$date = new \DateTime('11 January 2009 4:00:00pm');
|
||||
$entity->setRevisionCreationTime($date->getTimestamp());
|
||||
}
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
$otherRevisionIds = [];
|
||||
for ($i = 0; $i < $totalRevisions - 1; $i++) {
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
$otherRevisionIds[] = $entity->getRevisionId();
|
||||
}
|
||||
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-delete-form'));
|
||||
$this->submitForm([], 'Delete');
|
||||
|
||||
// The revision was deleted.
|
||||
$this->assertNull($storage->loadRevision($revisionId));
|
||||
// Make sure the other revisions were not deleted.
|
||||
foreach ($otherRevisionIds as $otherRevisionId) {
|
||||
$this->assertNotNull($storage->loadRevision($otherRevisionId));
|
||||
}
|
||||
|
||||
// Destination.
|
||||
if ($expectedDestination === 404) {
|
||||
$this->assertSession()->statusCodeEquals(404);
|
||||
}
|
||||
else {
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals($expectedDestination);
|
||||
}
|
||||
|
||||
// Logger log.
|
||||
$logs = $this->getLogs($entity->getEntityType()->getProvider());
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertEquals("@type: deleted %title revision %revision.", $logs[0]->message);
|
||||
$this->assertEquals($expectedLog, unserialize($logs[0]->variables));
|
||||
// Messenger message.
|
||||
$this->assertSession()->pageTextContains($expectedMessage);
|
||||
\Drupal::database()->delete('watchdog')->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testSubmitForm.
|
||||
*/
|
||||
public static function providerSubmitForm(): array {
|
||||
$data = [];
|
||||
|
||||
$data['not supporting revision log, one revision remaining after delete, no view access'] = [
|
||||
[],
|
||||
'entity_test_rev',
|
||||
'view all revisions, delete revision',
|
||||
2,
|
||||
[
|
||||
'@type' => 'entity_test_rev',
|
||||
'%title' => 'view all revisions, delete revision',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.',
|
||||
'/entity_test_rev/1/revisions',
|
||||
];
|
||||
|
||||
$data['not supporting revision log, one revision remaining after delete, view access'] = [
|
||||
['view test entity'],
|
||||
'entity_test_rev',
|
||||
'view, view all revisions, delete revision',
|
||||
2,
|
||||
[
|
||||
'@type' => 'entity_test_rev',
|
||||
'%title' => 'view, view all revisions, delete revision',
|
||||
'%revision' => '3',
|
||||
],
|
||||
'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.',
|
||||
'/entity_test_rev/2/revisions',
|
||||
];
|
||||
|
||||
$data['supporting revision log, one revision remaining after delete, no view access'] = [
|
||||
[],
|
||||
'entity_test_revlog',
|
||||
'view all revisions, delete revision',
|
||||
2,
|
||||
[
|
||||
'@type' => 'entity_test_revlog',
|
||||
'%title' => 'view all revisions, delete revision',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.',
|
||||
'/entity_test_revlog/1/revisions',
|
||||
];
|
||||
|
||||
$data['supporting revision log, one revision remaining after delete, view access'] = [
|
||||
[],
|
||||
'entity_test_revlog',
|
||||
'view, view all revisions, delete revision',
|
||||
2,
|
||||
[
|
||||
'@type' => 'entity_test_revlog',
|
||||
'%title' => 'view, view all revisions, delete revision',
|
||||
'%revision' => '3',
|
||||
],
|
||||
'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.',
|
||||
'/entity_test_revlog/2/revisions',
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads watchdog entries by channel.
|
||||
*
|
||||
* @param string $channel
|
||||
* The logger channel.
|
||||
*
|
||||
* @return string[]
|
||||
* Watchdog entries.
|
||||
*/
|
||||
protected function getLogs(string $channel): array {
|
||||
return \Drupal::database()->select('watchdog')
|
||||
->fields('watchdog')
|
||||
->condition('type', $channel)
|
||||
->execute()
|
||||
->fetchAll();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Entity\RevisionLogInterface;
|
||||
use Drupal\entity_test\Entity\EntityTestRev;
|
||||
use Drupal\entity_test\Entity\EntityTestRevPub;
|
||||
use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests reverting a revision with revision revert form.
|
||||
*
|
||||
* @group Entity
|
||||
* @group #slow
|
||||
* @coversDefaultClass \Drupal\Core\Entity\Form\RevisionRevertForm
|
||||
*/
|
||||
class RevisionRevertFormTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'block',
|
||||
'entity_test',
|
||||
'entity_test_revlog',
|
||||
'dblog',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->drupalPlaceBlock('page_title_block');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test form revision revert.
|
||||
*/
|
||||
public function testFormRevisionRevert(): void {
|
||||
foreach (self::providerPageTitle() as $page_title) {
|
||||
$this->testPageTitle($page_title[0], $page_title[1]);
|
||||
}
|
||||
$this->testAccessRevertLatestDefault();
|
||||
$this->testAccessRevertLatestForwardRevision();
|
||||
$this->testAccessRevertNonLatest();
|
||||
$this->testPrepareRevision();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests title by whether entity supports revision creation dates.
|
||||
*
|
||||
* @param string $entityTypeId
|
||||
* The entity type to test.
|
||||
* @param string $expectedQuestion
|
||||
* The expected question/page title.
|
||||
*/
|
||||
protected function testPageTitle(string $entityTypeId, string $expectedQuestion): void {
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
|
||||
|
||||
$entity = $storage->create([
|
||||
'type' => $entityTypeId,
|
||||
'name' => 'revert',
|
||||
]);
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$date = new \DateTime('11 January 2009 4:00:00pm');
|
||||
$entity->setRevisionCreationTime($date->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
// Create a new latest revision.
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
// Reload the entity.
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-revert-form'));
|
||||
$this->assertSession()->pageTextContains($expectedQuestion);
|
||||
$this->assertSession()->buttonExists('Revert');
|
||||
$this->assertSession()->linkExists('Cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testPageTitle.
|
||||
*/
|
||||
protected static function providerPageTitle(): array {
|
||||
return [
|
||||
['entity_test_rev', 'Are you sure you want to revert the revision?'],
|
||||
['entity_test_revlog', 'Are you sure you want to revert to the revision from Sun, 11 Jan 2009 - 16:00?'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot revert latest default revision.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessRevertLatestDefault(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create();
|
||||
$entity->setName('revert');
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('revision-revert-form'));
|
||||
$this->assertSession()->statusCodeEquals(403);
|
||||
$this->assertFalse($entity->access('revert', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that forward revisions can be reverted.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessRevertLatestForwardRevision(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRevPub::create();
|
||||
$entity->setName('revert');
|
||||
$entity->isDefaultRevision(TRUE);
|
||||
$entity->setPublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->isDefaultRevision(FALSE);
|
||||
$entity->setUnpublished();
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('revision-revert-form'));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertTrue($entity->access('revert', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test can revert non-latest revision.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\EntityAccessControlHandler::checkAccess
|
||||
*/
|
||||
protected function testAccessRevertNonLatest(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create();
|
||||
$entity->setName('revert');
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
// Reload the entity.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_rev');
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-revert-form'));
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertTrue($revision->access('revert', $this->rootUser, FALSE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests revision revert, and expected response after revert.
|
||||
*
|
||||
* @param array $permissions
|
||||
* If not empty, a user will be created and logged in with these
|
||||
* permissions.
|
||||
* @param string $entityTypeId
|
||||
* The entity type to test.
|
||||
* @param string $entityLabel
|
||||
* The entity label, which corresponds to access grants.
|
||||
* @param string $expectedLog
|
||||
* Expected log.
|
||||
* @param string $expectedMessage
|
||||
* Expected messenger message.
|
||||
* @param string $expectedDestination
|
||||
* Expected destination after deletion.
|
||||
*
|
||||
* @covers ::submitForm
|
||||
* @dataProvider providerSubmitForm
|
||||
*/
|
||||
public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, array $expectedLog, string $expectedMessage, string $expectedDestination): void {
|
||||
if (count($permissions) > 0) {
|
||||
$this->drupalLogin($this->createUser($permissions));
|
||||
}
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
|
||||
|
||||
$entity = $storage->create([
|
||||
'type' => $entityTypeId,
|
||||
'name' => $entityLabel,
|
||||
]);
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$date = new \DateTime('11 January 2009 4:00:00pm');
|
||||
$entity->setRevisionCreationTime($date->getTimestamp());
|
||||
}
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
if ($entity instanceof RevisionLogInterface) {
|
||||
$entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
|
||||
}
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision-revert-form'));
|
||||
|
||||
$count = $this->countRevisions($entityTypeId);
|
||||
$this->submitForm([], 'Revert');
|
||||
|
||||
// A new revision was created.
|
||||
$this->assertEquals($count + 1, $this->countRevisions($entityTypeId));
|
||||
|
||||
// Destination.
|
||||
$this->assertSession()->statusCodeEquals(200);
|
||||
$this->assertSession()->addressEquals($expectedDestination);
|
||||
|
||||
// Logger log.
|
||||
$logs = $this->getLogs($entity->getEntityType()->getProvider());
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertEquals('@type: reverted %title revision %revision.', $logs[0]->message);
|
||||
$this->assertEquals($expectedLog, unserialize($logs[0]->variables));
|
||||
|
||||
// Messenger message.
|
||||
$this->assertSession()->pageTextContains($expectedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testSubmitForm.
|
||||
*/
|
||||
public static function providerSubmitForm(): array {
|
||||
$data = [];
|
||||
|
||||
$data['not supporting revision log, no version history access'] = [
|
||||
['view test entity'],
|
||||
'entity_test_rev',
|
||||
'view, revert',
|
||||
[
|
||||
'@type' => 'entity_test_rev',
|
||||
'%title' => 'view, revert',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Entity Test Bundle view, revert has been reverted.',
|
||||
'/entity_test_rev/manage/1',
|
||||
];
|
||||
|
||||
$data['not supporting revision log, version history access'] = [
|
||||
['view test entity'],
|
||||
'entity_test_rev',
|
||||
'view, view all revisions, revert',
|
||||
[
|
||||
'@type' => 'entity_test_rev',
|
||||
'%title' => 'view, view all revisions, revert',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Entity Test Bundle view, view all revisions, revert has been reverted.',
|
||||
'/entity_test_rev/1/revisions',
|
||||
];
|
||||
|
||||
$data['supporting revision log, no version history access'] = [
|
||||
[],
|
||||
'entity_test_revlog',
|
||||
'view, revert',
|
||||
[
|
||||
'@type' => 'entity_test_revlog',
|
||||
'%title' => 'view, revert',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Test entity - revisions log view, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.',
|
||||
'/entity_test_revlog/manage/1',
|
||||
];
|
||||
|
||||
$data['supporting revision log, version history access'] = [
|
||||
[],
|
||||
'entity_test_revlog',
|
||||
'view, view all revisions, revert',
|
||||
[
|
||||
'@type' => 'entity_test_revlog',
|
||||
'%title' => 'view, view all revisions, revert',
|
||||
'%revision' => '1',
|
||||
],
|
||||
'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.',
|
||||
'/entity_test_revlog/1/revisions',
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the revert process.
|
||||
*
|
||||
* @covers ::prepareRevision
|
||||
*/
|
||||
protected function testPrepareRevision(): void {
|
||||
$user = $this->createUser();
|
||||
$this->drupalLogin($user);
|
||||
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create([
|
||||
'type' => 'entity_test_revlog',
|
||||
'name' => 'revert',
|
||||
]);
|
||||
|
||||
$date = new \DateTime('11 January 2009 4:00:00pm');
|
||||
$entity->setRevisionCreationTime($date->getTimestamp());
|
||||
$entity->isDefaultRevision(TRUE);
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$revisionCreationTime = $date->modify('+1 hour')->getTimestamp();
|
||||
$entity->setRevisionCreationTime($revisionCreationTime);
|
||||
$entity->setRevisionUserId(0);
|
||||
$entity->isDefaultRevision(FALSE);
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
$targetRevertRevisionId = $entity->getRevisionId();
|
||||
|
||||
// Create a another revision so the previous revision can be reverted to.
|
||||
$entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp());
|
||||
$entity->isDefaultRevision(FALSE);
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$count = $this->countRevisions($entity->getEntityTypeId());
|
||||
|
||||
// Load the revision to be copied.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId());
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $targetRevision */
|
||||
$targetRevision = $storage->loadRevision($targetRevertRevisionId);
|
||||
|
||||
$this->drupalGet($targetRevision->toUrl('revision-revert-form'));
|
||||
$this->submitForm([], 'Revert');
|
||||
|
||||
// Load the new latest revision.
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $latestRevision */
|
||||
$latestRevision = $storage->loadUnchanged($entity->id());
|
||||
$this->assertEquals($count + 1, $this->countRevisions($entity->getEntityTypeId()));
|
||||
$this->assertEquals('Copy of the revision from <em class="placeholder">Sun, 11 Jan 2009 - 17:00</em>.', $latestRevision->getRevisionLogMessage());
|
||||
$this->assertGreaterThan($revisionCreationTime, $latestRevision->getRevisionCreationTime());
|
||||
$this->assertEquals($user->id(), $latestRevision->getRevisionUserId());
|
||||
$this->assertTrue($latestRevision->isDefaultRevision());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads watchdog entries by channel.
|
||||
*
|
||||
* @param string $channel
|
||||
* The logger channel.
|
||||
*
|
||||
* @return string[]
|
||||
* Watchdog entries.
|
||||
*/
|
||||
protected function getLogs(string $channel): array {
|
||||
return \Drupal::database()->select('watchdog')
|
||||
->fields('watchdog')
|
||||
->condition('type', $channel)
|
||||
->execute()
|
||||
->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count number of revisions for an entity type.
|
||||
*
|
||||
* @param string $entityTypeId
|
||||
* The entity type.
|
||||
*
|
||||
* @return int
|
||||
* Number of revisions for an entity type.
|
||||
*/
|
||||
protected function countRevisions(string $entityTypeId): int {
|
||||
return (int) \Drupal::entityTypeManager()->getStorage($entityTypeId)
|
||||
->getQuery()
|
||||
->accessCheck(FALSE)
|
||||
->allRevisions()
|
||||
->count()
|
||||
->execute();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\entity_test\Entity\EntityTestRev;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests revision route provider.
|
||||
*
|
||||
* @group Entity
|
||||
* @coversDefaultClass \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider
|
||||
*/
|
||||
class RevisionRouteProviderTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'block',
|
||||
'entity_test',
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->drupalPlaceBlock('page_title_block');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests title is from revision in context.
|
||||
*/
|
||||
public function testRevisionTitle(): void {
|
||||
$entity = EntityTestRev::create();
|
||||
$entity
|
||||
->setName('first revision, view revision')
|
||||
->setNewRevision();
|
||||
$entity->save();
|
||||
$revisionId = $entity->getRevisionId();
|
||||
|
||||
// A default revision is created to ensure it is not pulled from the
|
||||
// non-revision entity parameter.
|
||||
$entity
|
||||
->setName('second revision, view revision')
|
||||
->setNewRevision();
|
||||
$entity->isDefaultRevision(TRUE);
|
||||
$entity->save();
|
||||
|
||||
// Reload the object.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_rev');
|
||||
$revision = $storage->loadRevision($revisionId);
|
||||
$this->drupalGet($revision->toUrl('revision'));
|
||||
$this->assertSession()->responseContains('first revision');
|
||||
$this->assertSession()->responseNotContains('second revision');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\FunctionalTests\Entity;
|
||||
|
||||
use Drupal\Core\Entity\Controller\VersionHistoryController;
|
||||
use Drupal\entity_test\Entity\EntityTestRev;
|
||||
use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog;
|
||||
use Drupal\Tests\BrowserTestBase;
|
||||
|
||||
/**
|
||||
* Tests version history page.
|
||||
*
|
||||
* @group Entity
|
||||
* @group #slow
|
||||
* @coversDefaultClass \Drupal\Core\Entity\Controller\VersionHistoryController
|
||||
*/
|
||||
class RevisionVersionHistoryTest extends BrowserTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $defaultTheme = 'stark';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static $modules = [
|
||||
'entity_test',
|
||||
'entity_test_revlog',
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
* Test all revisions appear, in order of revision creation.
|
||||
*/
|
||||
public function testOrder(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
// Need label to be able to assert order.
|
||||
$entity->setName('view all revisions');
|
||||
$user = $this->drupalCreateUser([], 'first revision');
|
||||
$entity->setRevisionUser($user);
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$user = $this->drupalCreateUser([], 'second revision');
|
||||
$entity->setRevisionUser($user);
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$user = $this->drupalCreateUser([], 'third revision');
|
||||
$entity->setRevisionUser($user);
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
// Order is newest to oldest revision by creation order.
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'third revision');
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(2)', 'second revision');
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(3)', 'first revision');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test current revision is indicated.
|
||||
*
|
||||
* @covers \Drupal\Core\Entity\Controller\VersionHistoryController::revisionOverview
|
||||
*/
|
||||
public function testCurrentRevision(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create(['type' => 'entity_test_rev']);
|
||||
// Need label to be able to assert order.
|
||||
$entity->setName('view all revisions');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
// Current revision text is found on the latest revision row.
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
|
||||
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(2)', 'Current revision');
|
||||
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(3)', 'Current revision');
|
||||
// Current revision row has 'revision-current' class.
|
||||
$this->assertSession()->elementAttributeContains('css', 'table tbody tr:nth-child(1)', 'class', 'revision-current');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test description with entity implementing revision log.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionRevLog(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions');
|
||||
$user = $this->drupalCreateUser([], $this->randomMachineName());
|
||||
$entity->setRevisionUser($user);
|
||||
$entity->setRevisionCreationTime((new \DateTime('2 February 2013 4:00:00pm'))->getTimestamp());
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '2 Feb 2013 - 16:00');
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $user->getAccountName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test description with entity implementing revision log, with empty values.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionRevLogNullValues(): void {
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions')->save();
|
||||
|
||||
// Check entity values are still null after saving; they did not receive
|
||||
// values from currentUser or some other global context.
|
||||
$this->assertNull($entity->getRevisionUser());
|
||||
$this->assertNull($entity->getRevisionUserId());
|
||||
$this->assertNull($entity->getRevisionLogMessage());
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'by Anonymous (not verified)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test description with entity, without revision log, no label access.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionNoRevLogNoLabelAccess(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create(['type' => 'entity_test_rev']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -');
|
||||
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', $entity->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test description with entity, without revision log, with label access.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionNoRevLogWithLabelAccess(): void {
|
||||
// Permission grants 'view label' access.
|
||||
$this->drupalLogin($this->createUser(['view test entity']));
|
||||
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create(['type' => 'entity_test_rev']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $entity->getName());
|
||||
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test revision link, without access to revision page.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionLinkNoAccess(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 1);
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr a', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test revision link, with access to revision page.
|
||||
*
|
||||
* Test two revisions. Usually the latest revision only checks canonical
|
||||
* route access, whereas all others will check individual revision access.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionLinkWithAccess(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
// Revision has access to individual revision.
|
||||
$entity->setName('view all revisions, view revision');
|
||||
$entity->save();
|
||||
$firstRevisionId = $entity->getRevisionId();
|
||||
|
||||
// Revision has access to canonical route.
|
||||
$entity->setName('view all revisions, view');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$row1Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1) a');
|
||||
$this->assertEquals($entity->toUrl()->toString(), $row1Link->getAttribute('href'));
|
||||
// Reload revision so object has the properties to build a revision link.
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_revlog');
|
||||
$firstRevision = $storage->loadRevision($firstRevisionId);
|
||||
$row2Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2) a');
|
||||
$this->assertEquals($firstRevision->toUrl('revision')->toString(), $row2Link->getAttribute('href'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test revision log message if supported, and HTML tags are stripped.
|
||||
*
|
||||
* @covers ::getRevisionDescription
|
||||
*/
|
||||
public function testDescriptionRevisionLogMessage(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->setRevisionLogMessage('<em>Hello</em> <script>world</script> <strong>123</strong>');
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
// Script tags are stripped, while admin-safe tags are retained.
|
||||
$this->assertSession()->elementContains('css', 'table tbody tr:nth-child(1)', '<em>Hello</em> world <strong>123</strong>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test revert operation.
|
||||
*
|
||||
* @covers ::buildRevertRevisionLink
|
||||
*/
|
||||
public function testOperationRevertRevision(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->save();
|
||||
|
||||
$entity->setName('view all revisions, revert');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->setName('view all revisions, revert');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
|
||||
// Latest revision does not have revert revision operation: reverting latest
|
||||
// revision is not permitted.
|
||||
$row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
|
||||
$this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row1);
|
||||
|
||||
// Revision 2 has revert revision operation: granted access.
|
||||
$row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)');
|
||||
$this->assertSession()->elementExists('named', ['link', 'Revert'], $row2);
|
||||
|
||||
// Revision 3 does not have revert revision operation: no access.
|
||||
$row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)');
|
||||
$this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row3);
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test delete operation.
|
||||
*
|
||||
* @covers ::buildDeleteRevisionLink
|
||||
*/
|
||||
public function testOperationDeleteRevision(): void {
|
||||
/** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */
|
||||
$entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']);
|
||||
$entity->setName('view all revisions');
|
||||
$entity->save();
|
||||
|
||||
$entity->setName('view all revisions, delete revision');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$entity->setName('view all revisions, delete revision');
|
||||
$entity->setNewRevision();
|
||||
$entity->save();
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
|
||||
// Latest revision does not have delete revision operation: deleting latest
|
||||
// revision is not permitted.
|
||||
$row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
|
||||
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
|
||||
$this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row1);
|
||||
|
||||
// Revision 2 has delete revision operation: granted access.
|
||||
$row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)');
|
||||
$this->assertSession()->elementExists('named', ['link', 'Delete'], $row2);
|
||||
|
||||
// Revision 3 does not have delete revision operation: no access.
|
||||
$row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)');
|
||||
$this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row3);
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test revisions are paginated.
|
||||
*/
|
||||
public function testRevisionsPagination(): void {
|
||||
/** @var \Drupal\entity_test\Entity\EntityTestRev $entity */
|
||||
$entity = EntityTestRev::create([
|
||||
'type' => 'entity_test_rev',
|
||||
'name' => 'view all revisions,view revision',
|
||||
]);
|
||||
$entity->save();
|
||||
|
||||
$firstRevisionId = $entity->getRevisionId();
|
||||
|
||||
for ($i = 0; $i < VersionHistoryController::REVISIONS_PER_PAGE; $i++) {
|
||||
$entity->setNewRevision(TRUE);
|
||||
// We need to change something on the entity for it to be considered a new
|
||||
// revision to display. We need "view all revisions" and "view revision"
|
||||
// in a comma separated string to grant access.
|
||||
$entity->setName('view all revisions,view revision,' . $i)->save();
|
||||
}
|
||||
|
||||
$this->drupalGet($entity->toUrl('version-history'));
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', VersionHistoryController::REVISIONS_PER_PAGE);
|
||||
$this->assertSession()->elementExists('css', '.pager');
|
||||
|
||||
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
|
||||
$storage = $this->container->get('entity_type.manager')->getStorage($entity->getEntityTypeId());
|
||||
$firstRevision = $storage->loadRevision($firstRevisionId);
|
||||
$secondRevision = $storage->loadRevision($firstRevisionId + 1);
|
||||
// We should see everything up to the second revision, but not the first.
|
||||
$this->assertSession()->linkByHrefExists($secondRevision->toUrl('revision')->toString());
|
||||
$this->assertSession()->linkByHrefNotExists($firstRevision->toUrl('revision')->toString());
|
||||
// The next page should show just the first revision.
|
||||
$this->clickLink('Go to next page');
|
||||
$this->assertSession()->elementsCount('css', 'table tbody tr', 1);
|
||||
$this->assertSession()->elementExists('css', '.pager');
|
||||
$this->assertSession()->linkByHrefNotExists($secondRevision->toUrl('revision')->toString());
|
||||
$this->assertSession()->linkByHrefExists($firstRevision->toUrl('revision')->toString());
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user