From 38a911b6f8dfd48325d2bc9f763652bb85b677fa Mon Sep 17 00:00:00 2001 From: Rodrigo Espinoza Date: Wed, 27 May 2026 10:56:26 -0600 Subject: [PATCH 1/4] Updating Fire commands - adding playwright VRT integration --- AGENTS.md | 15 ++ assets/templates/fire.yml | 3 + .../playwright/css/screenshotGlobalStyle.css | 7 + .../playwright/playwright.atk.config.js | 45 +++++ .../templates/playwright/playwright.config.js | 72 +++++++ .../playwright/tests/support/aft_utilities.js | 58 ++++++ .../playwright/tests/vrt/homepage.spec.js | 58 ++++++ .../tests/vrt/page_components.spec.js | 63 ++++++ src/Robo/Plugin/Commands/VrtBase.php | 18 ++ .../Commands/VrtPlaywrightInitCommand.php | 181 ++++++++++++++++++ .../Plugin/Commands/VrtReferenceCommand.php | 8 +- src/Robo/Plugin/Commands/VrtRunCommand.php | 10 +- src/Robo/Plugin/Commands/VrtinitCommand.php | 15 +- 13 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 assets/templates/playwright/css/screenshotGlobalStyle.css create mode 100644 assets/templates/playwright/playwright.atk.config.js create mode 100644 assets/templates/playwright/playwright.config.js create mode 100644 assets/templates/playwright/tests/support/aft_utilities.js create mode 100644 assets/templates/playwright/tests/vrt/homepage.spec.js create mode 100644 assets/templates/playwright/tests/vrt/page_components.spec.js create mode 100644 src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d7a74c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS + +## Entrypoint +- CLI entrypoint is `bin/fire` (executes `bin/fire.php`); it requires Composer autoload to exist, so run `composer install` before local runs. + +## Config + discovery +- `fire.yml` and optional `fire.local.yml` are read from the project root (four directories above `vendor/fourkitchens/fire`); if missing, only `InitCommand.php` is registered and the CLI prompts to run `fire init`. +- Local env auto-detection sets `local_environment` based on `.lando.yml` or `.ddev/config.yaml` in the project root. + +## Command sources +- Core commands live in `src/Robo/Plugin/Commands/*Command.php` and are discovered via Robo `CommandFileDiscovery`. +- Project-level custom commands are loaded from `fire/src/Commands` in the project root (path is computed by stripping `vendor/fourkitchens/` from the package path). + +## Config template +- `fire init` uses the template at `assets/templates/fire.yml`; update it if you change required config fields. diff --git a/assets/templates/fire.yml b/assets/templates/fire.yml index 9f375b0..cb5125e 100644 --- a/assets/templates/fire.yml +++ b/assets/templates/fire.yml @@ -18,3 +18,6 @@ remote_canonical_env: live # (Optional setting), the system will automatically detected your local env, currently available: ddev, lando. #local_environment : lando + +# (Optional setting), default VRT tool to use. Options: backstop, playwright. +#vrt_tool: backstop diff --git a/assets/templates/playwright/css/screenshotGlobalStyle.css b/assets/templates/playwright/css/screenshotGlobalStyle.css new file mode 100644 index 0000000..ad14ac2 --- /dev/null +++ b/assets/templates/playwright/css/screenshotGlobalStyle.css @@ -0,0 +1,7 @@ +#toolbar-administration { + visibility: hidden; +} + +.gin-secondary-toolbar__layout-container { + visibility: hidden; +} diff --git a/assets/templates/playwright/playwright.atk.config.js b/assets/templates/playwright/playwright.atk.config.js new file mode 100644 index 0000000..8d10580 --- /dev/null +++ b/assets/templates/playwright/playwright.atk.config.js @@ -0,0 +1,45 @@ +/* +* Automated Testing Kit configuration. +*/ +var currentEnv = process.env.TERMINUS_ENV || 'local'; +var runOnPantheon = false; +if (currentEnv != 'local') { + runOnPantheon = true; +} + +module.exports = { + operatingMode: "native", + drushCmd: "__DRUSH_CMD__", + articleAddUrl: 'node/add/news_article', + contactUsUrl: "form/contact", + logInUrl: "user/login", + logOutUrl: "user/logout", + imageAddUrl: 'media/add/image', + mediaDeleteUrl: 'media/{mid}/delete', + mediaEditUrl: 'media/{mid}/edit', + mediaList: 'admin/content/media', + menuAddUrl: 'admin/structure/menu/manage/main/add', + menuDeleteUrl: 'admin/structure/menu/item/{mid}/delete', + menuEditUrl: 'admin/structure/menu/item/{mid}/edit', + menuListUrl: 'admin/structure/menu/manage/main', + nodeDeleteUrl: 'node/{nid}/delete', + nodeEditUrl: 'node/{nid}/edit', + pageAddUrl: 'node/add/page', + registerUrl: "user/register", + resetPasswordUrl: "user/password", + termAddUrl: 'admin/structure/taxonomy/manage/file_categories/add', + termEditUrl: 'taxonomy/term/{tid}/edit', + termDeleteUrl: 'taxonomy/term/{tid}/delete', + termListUrl: 'admin/structure/taxonomy/manage/file_categories/overview', + termViewUrl: 'taxonomy/term/{tid}', + xmlSitemapUrl: 'admin/config/search/simplesitemap', + authDir: "tests/support", + dataDir: "tests/data", + supportDir: "tests/support", + testDir: "tests", + pantheon : { + isTarget: runOnPantheon, + site: "__PANTHEON_SITE__", + environment: currentEnv + } +} diff --git a/assets/templates/playwright/playwright.config.js b/assets/templates/playwright/playwright.config.js new file mode 100644 index 0000000..bfcce0e --- /dev/null +++ b/assets/templates/playwright/playwright.config.js @@ -0,0 +1,72 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); +import path from 'path' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +require('dotenv').config(); + +const configuredBaseURL = (process.env.REMOTE_ENV_BASE_URL || '__DEFAULT_BASE_URL__') + .trim() + .replace(/\/+$/, '') + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + // Timeout. + timeout: 100000, + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'blob' : 'html', + + expect: { + // timeout per assertion. + timeout: 100000, + toHaveScreenshot: { + stylePath: './css/screenshotGlobalStyle.css' + } + }, + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `${configuredBaseURL}/`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on', + launchOptions: { + slowMo: 0 + } + }, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + userAgent: 'aft-main-playwright-ci/1.0' + }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], +}); diff --git a/assets/templates/playwright/tests/support/aft_utilities.js b/assets/templates/playwright/tests/support/aft_utilities.js new file mode 100644 index 0000000..3a953af --- /dev/null +++ b/assets/templates/playwright/tests/support/aft_utilities.js @@ -0,0 +1,58 @@ +const { devices } = require('@playwright/test') +// Helper function for lazy loading images. +const forceLoadLazyImages = async (page) => { + await page.evaluate(async () => { + document.querySelectorAll('[decoding="async"]').forEach((element) => { + element.decoding = 'sync' + }) + + const images = Array.from(document.querySelectorAll('img[loading="lazy"]')) + + for (const image of images) { + image.loading = 'eager' + image.removeAttribute('loading') + + try { + await image.decode() + } catch (error) { + // Ignore decode errors for lazy images. + } + } + }) +} + +const normalizeJoinTheMovementSignup = async (page) => { + const zipCodeField = page.locator('#form-zip_code') + + await zipCodeField.first().waitFor({ state: 'attached', timeout: 10000 }).catch(() => {}) + + await zipCodeField.evaluateAll((fields) => { + fields.forEach((field) => { + field.setAttribute('placeholder', 'Zip') + }) + }) +} +// Returns devices profiles for VRT. +const vrtDeviceProfiles = (() => { + const desktopChromeProfile = { ...devices['Desktop Chrome'] } + const iPadProfile = { ...devices['iPad (gen 7)'] } + const iPhone12Profile = { ...devices['iPhone 12'] } + + delete desktopChromeProfile.defaultBrowserType + delete iPadProfile.defaultBrowserType + delete iPhone12Profile.defaultBrowserType + + return { + desktopChrome: desktopChromeProfile, + iPad: iPadProfile, + iPhone12: iPhone12Profile, + } +})() + +const getVrtDeviceProfile = (key) => vrtDeviceProfiles[key] + +export { + forceLoadLazyImages, + getVrtDeviceProfile, + normalizeJoinTheMovementSignup, +} diff --git a/assets/templates/playwright/tests/vrt/homepage.spec.js b/assets/templates/playwright/tests/vrt/homepage.spec.js new file mode 100644 index 0000000..4997af8 --- /dev/null +++ b/assets/templates/playwright/tests/vrt/homepage.spec.js @@ -0,0 +1,58 @@ +import * as atkCommands from '../support/atk_commands' +import * as aftUtilities from '../support/aft_utilities' + +const { test, expect } = require('@playwright/test'); + +//test.describe.configure({ mode: 'serial' }); + +const runHomepageVrtTest = async ({ page, screenshotName }) => { + + await page.goto('/'); + // Force-load lazy images. + await aftUtilities.forceLoadLazyImages(page); + await page.waitForLoadState('networkidle'); + + await page.addStyleTag({ + content: ` + * { + animation: none !important; + transition: none !important; + } + ` + }); + + await expect(page).toHaveScreenshot(screenshotName, { fullPage: true }); +} + +test.describe('Homepage VRT - Desktop', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('desktopChrome') }); + + test('Homepage VRT - desktop @vrt', async ({ page }) => { + await runHomepageVrtTest({ + page, + screenshotName: 'homepage-desktop.png', + }); + }); +}); + +test.describe('Homepage VRT - iPad', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('iPad') }); + + test('Homepage VRT - ipad @vrt', async ({ page }) => { + await runHomepageVrtTest({ + page, + screenshotName: 'homepage-ipad.png', + }); + }); +}); + +test.describe('Homepage VRT - iPhone 12', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('iPhone12') }); + + test('Homepage VRT - iphone @vrt', async ({ page }) => { + await runHomepageVrtTest({ + page, + screenshotName: 'homepage-iphone-12.png', + }); + }); +}); diff --git a/assets/templates/playwright/tests/vrt/page_components.spec.js b/assets/templates/playwright/tests/vrt/page_components.spec.js new file mode 100644 index 0000000..52da60c --- /dev/null +++ b/assets/templates/playwright/tests/vrt/page_components.spec.js @@ -0,0 +1,63 @@ +import * as atkCommands from '../support/atk_commands' +import * as aftUtilities from '../support/aft_utilities' + + +const { test, expect } = require('@playwright/test'); + +//test.describe.configure({ mode: 'serial' }); + +const runComponentsVrtTest = async ({ page, context, screenshotName }) => { + await atkCommands.logInViaUli(page, context, 1) + await page.goto('/vrt-components-test-dont-delete'); + // Force-load lazy images. + await aftUtilities.forceLoadLazyImages(page); + await aftUtilities.normalizeJoinTheMovementSignup(page); + await page.waitForLoadState('networkidle'); + + await page.addStyleTag({ + content: ` + * { + animation: none !important; + transition: none !important; + } + ` + }); + + await expect(page).toHaveScreenshot(screenshotName, { fullPage: true }); +} + +test.describe('Components page VRT - Desktop', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('desktopChrome') }); + + test('Components page VRT @vrt', async ({ page, context }) => { + await runComponentsVrtTest({ + page, + context, + screenshotName: 'components-desktop.png', + }); + }); +}); + +test.describe('Components page VRT - iPad', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('iPad') }); + + test('Components page VRT @vrt', async ({ page, context }) => { + await runComponentsVrtTest({ + page, + context, + screenshotName: 'components-ipad.png', + }); + }); +}); + +test.describe('Components page VRT - iPhone 12', () => { + test.use({ ...aftUtilities.getVrtDeviceProfile('iPhone12') }); + + test('Components page VRT @vrt', async ({ page, context }) => { + await runComponentsVrtTest({ + page, + context, + screenshotName: 'components-iphone-12.png', + }); + }); +}); diff --git a/src/Robo/Plugin/Commands/VrtBase.php b/src/Robo/Plugin/Commands/VrtBase.php index c236f13..eec6ea4 100644 --- a/src/Robo/Plugin/Commands/VrtBase.php +++ b/src/Robo/Plugin/Commands/VrtBase.php @@ -66,6 +66,24 @@ protected function backstopTaskExec(ConsoleIO $io, string $backstopCommand) { } } + /** + * Similar to taskExec(), but for Playwright commands. + * + * @param ConsoleIO $io + * @param string $playwrightCommand + * E.g. 'test --grep @vrt' + * + * @return \Robo\Collection\CollectionBuilder|\Robo\Task\Base\Exec + */ + protected function playwrightTaskExec(ConsoleIO $io, string $playwrightCommand) { + $this->io = $io; + $playwrightRoot = $this->getLocalEnvRoot() . '/tests/playwright'; + if (!is_dir($playwrightRoot)) { + throw new AbortTasksException("Playwright tests folder not found at $playwrightRoot. Run 'fire vrt:playwright:init' first."); + } + return $this->taskExec('npx playwright ' . $playwrightCommand)->dir($playwrightRoot); + } + /** * Regenerate cookies for this hosting environment, if there's a script for it. * diff --git a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php new file mode 100644 index 0000000..ae99aa9 --- /dev/null +++ b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php @@ -0,0 +1,181 @@ + FALSE]) { + $projectRoot = $this->getLocalEnvRoot(); + $drupalRoot = $this->getDrupalRoot(); + $atkHome = getenv('ATK_HOME'); + if (!$atkHome) { + throw new AbortTasksException('ATK_HOME is not set in your environment. Add `export ATK_HOME=./tests/playwright` to your shell profile (PATH) and rerun.'); + } + $testsRoot = $this->resolveAtkHomePath($projectRoot, $atkHome); + + $shouldProceed = TRUE; + if (!$opts['y']) { + $shouldProceed = $io->confirm("This action will generate/update Playwright VRT scaffolding in $testsRoot. Continue?", TRUE); + } + + if (!$shouldProceed) { + $io->warning('Playwright VRT init skipped.'); + return 0; + } + + $tasks = $this->collectionBuilder($io); + + $tasks->addTask($this->taskExec("composer require 'drupal/automated_testing_kit'")->dir($projectRoot)); + + $atkSetup = $drupalRoot . '/modules/contrib/automated_testing_kit/module_support/atk_setup'; + if (!file_exists($atkSetup)) { + throw new AbortTasksException("Automated Testing Kit not found at $atkSetup. Install drupal/automated_testing_kit and rerun."); + } + + $atkCommand = 'ATK_HOME=' . $atkHome . ' ' . $atkSetup . ' playwright'; + $tasks->addTask($this->taskExec($atkCommand)->dir($projectRoot)); + $tasks->addTask($this->taskExec($atkCommand)->dir($projectRoot)); + + $testsDir = $testsRoot . '/tests'; + if (is_dir($testsDir)) { + $testItems = glob($testsDir . '/*') ?: []; + foreach ($testItems as $testItem) { + if (is_dir($testItem)) { + $folderName = basename($testItem); + if (in_array($folderName, ['support', 'data', 'vrt'], TRUE)) { + continue; + } + } + $tasks->addTask($this->taskFilesystemStack()->remove($testItem)); + } + } + + if (!is_dir($testsRoot . '/tests/vrt')) { + $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/tests/vrt')); + } + if (!is_dir($testsRoot . '/tests/support')) { + $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support')); + } + if (!is_dir($testsRoot . '/css')) { + $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/css')); + } + + $assets = dirname(__DIR__, 4) . '/assets/templates/playwright/'; + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'playwright.config.js', $testsRoot . '/playwright.config.js', TRUE)); + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'playwright.atk.config.js', $testsRoot . '/playwright.atk.config.js', TRUE)); + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'css/screenshotGlobalStyle.css', $testsRoot . '/css/screenshotGlobalStyle.css', TRUE)); + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/support/aft_utilities.js', $testsRoot . '/tests/support/aft_utilities.js', TRUE)); + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/vrt/homepage.spec.js', $testsRoot . '/tests/vrt/homepage.spec.js', TRUE)); + $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/vrt/page_components.spec.js', $testsRoot . '/tests/vrt/page_components.spec.js', TRUE)); + + $gitignorePath = $testsRoot . '/.gitignore'; + $gitignoreTask = $this->taskWriteToFile($gitignorePath); + if (file_exists($gitignorePath)) { + $gitignoreTask->textFromFile($gitignorePath); + } + $gitignoreTask + ->appendUnlessMatches('/node_modules\//', "node_modules/\n") + ->appendUnlessMatches('/\/test-results\//', "/test-results/\n") + ->appendUnlessMatches('/\/playwright-report\//', "/playwright-report/\n") + ->appendUnlessMatches('/\/blob-report\//', "/blob-report/\n") + ->appendUnlessMatches('/\/cache\//', "/cache/\n") + ->appendUnlessMatches('/\/\.auth\//', "/.auth/\n") + ->appendUnlessMatches('/\/tests\/support\/loginAuth\.json/', "/tests/support/loginAuth.json\n") + ->appendUnlessMatches('/\.env/', ".env\n") + ->appendUnlessMatches('/\*\.spec\.js-snapshots/', "*.spec.js-snapshots\n"); + $tasks->addTask($gitignoreTask); + + $defaultBaseUrl = $this->getDefaultBaseUrl($projectRoot); + $tasks->addTask( + $this->taskReplaceInFile($testsRoot . '/playwright.config.js') + ->from('__DEFAULT_BASE_URL__') + ->to($defaultBaseUrl) + ); + + $drushCmd = $this->getDefaultDrushCommand(); + $pantheonSite = Robo::config()->get('remote_sitename') ?: 'remote-pantheon-site-machine-name'; + $tasks->addTask( + $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') + ->from('__DRUSH_CMD__') + ->to($drushCmd) + ); + $tasks->addTask( + $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') + ->from('__PANTHEON_SITE__') + ->to($pantheonSite) + ); + + return $tasks; + } + + /** + * Resolve ATK_HOME into an absolute path. + */ + private function resolveAtkHomePath(string $projectRoot, string $atkHome) { + $atkHome = trim($atkHome); + if ($atkHome === '') { + return $projectRoot . '/tests/playwright'; + } + if (strpos($atkHome, '/') === 0) { + return rtrim($atkHome, '/'); + } + $relative = ltrim($atkHome, './'); + return rtrim($projectRoot, '/') . '/' . $relative; + } + + /** + * Infer the best default base URL from local env config. + */ + private function getDefaultBaseUrl(string $projectRoot) { + $defaultBaseUrl = 'http://mysite.site'; + $env = Robo::config()->get('local_environment'); + + if ($env === 'lando' && file_exists($projectRoot . '/.lando.yml')) { + $landoConfig = Yaml::parse(file_get_contents($projectRoot . '/.lando.yml')); + if (isset($landoConfig['name'])) { + return 'https://' . $landoConfig['name'] . '.lndo.site'; + } + } + + if ($env === 'ddev' && file_exists($projectRoot . '/.ddev/config.yaml')) { + $ddevConfig = Yaml::parse(file_get_contents($projectRoot . '/.ddev/config.yaml')); + if (isset($ddevConfig['name'])) { + return 'https://' . $ddevConfig['name'] . '.ddev.site'; + } + } + + return $defaultBaseUrl; + } + + /** + * Determine the local drush command to use. + */ + private function getDefaultDrushCommand() { + $env = Robo::config()->get('local_environment'); + if ($env === 'lando') { + return 'lando drush'; + } + if ($env === 'ddev') { + return 'ddev drush'; + } + return 'drush'; + } + +} diff --git a/src/Robo/Plugin/Commands/VrtReferenceCommand.php b/src/Robo/Plugin/Commands/VrtReferenceCommand.php index bd33227..8b06ed3 100644 --- a/src/Robo/Plugin/Commands/VrtReferenceCommand.php +++ b/src/Robo/Plugin/Commands/VrtReferenceCommand.php @@ -3,7 +3,6 @@ namespace Fire\Robo\Plugin\Commands; use Robo\Symfony\ConsoleIO; -use Robo\Robo; /** * Provides to generate the reference files for backstop. @@ -17,9 +16,14 @@ class VrtReferenceCommand extends VrtBase { * * @command vrt:reference * @aliases vref + * @option $tool Choose backstop or playwright (default: backstop). * */ - public function vrtReference(ConsoleIO $io, array $args) { + public function vrtReference(ConsoleIO $io, array $args, $opts = ['tool' => 'backstop']) { + $tool = $this->resolveVrtTool($opts, $io); + if ($tool === 'playwright') { + return $this->playwrightTaskExec($io, 'test --grep @vrt --update-snapshots')->run(); + } return $this->backstopTaskExec($io, 'reference')->run(); } } diff --git a/src/Robo/Plugin/Commands/VrtRunCommand.php b/src/Robo/Plugin/Commands/VrtRunCommand.php index eddb6f8..c4a03e0 100644 --- a/src/Robo/Plugin/Commands/VrtRunCommand.php +++ b/src/Robo/Plugin/Commands/VrtRunCommand.php @@ -18,9 +18,15 @@ class VrtRunCommand extends VrtBase { * * @command vrt:run * @aliases vrun + * @option $tool Choose backstop or playwright (default: backstop). * */ - public function vrtRun(ConsoleIO $io) { + public function vrtRun(ConsoleIO $io, $opts = ['tool' => 'backstop']) { + $tool = $this->resolveVrtTool($opts, $io); + if ($tool === 'playwright') { + return $this->playwrightTaskExec($io, 'test --grep @vrt')->run(); + } + $env = Robo::config()->get('local_environment'); $reconfigureTestingUrls = $io->confirm('Do you want to reconfigure your reference and test urls?', TRUE); $newReferenceFiles = $io->confirm('Do you want to re-take the reference screenshots?', TRUE); @@ -34,7 +40,7 @@ public function vrtRun(ConsoleIO $io) { $this->backstopTaskExec($io, 'test')->run(); // Sometimes there can be a slight delay before the files are available in the Docker container. sleep(1); - + if ($env === 'lando') { $landoConfig = Yaml::parse(file_get_contents($this->getLocalEnvRoot() . '/.lando.yml')); $this->taskOpenBrowser('https://' . $landoConfig['name'] . '.lndo.site/backstop_data/html_report/index.html')->run(); diff --git a/src/Robo/Plugin/Commands/VrtinitCommand.php b/src/Robo/Plugin/Commands/VrtinitCommand.php index 23f80cc..41df041 100644 --- a/src/Robo/Plugin/Commands/VrtinitCommand.php +++ b/src/Robo/Plugin/Commands/VrtinitCommand.php @@ -3,7 +3,6 @@ namespace Fire\Robo\Plugin\Commands; use Robo\Symfony\ConsoleIO; -use Robo\Robo; /** * Provides a command to initialize VRT. @@ -17,14 +16,20 @@ class VrtinitCommand extends VrtBase { * * @command vrt:init * @aliases vinit + * @option $tool Choose backstop or playwright (default: backstop). + * @option $y Run the command with no interection required. * */ public function vrtInit(ConsoleIO $io) { - $env = Robo::config()->get('local_environment'); + $tool = $io->choice("Select the vrt tool:", ['backstopjs(deprecated)', 'playwright']); $tasks = $this->collectionBuilder($io); - $tasks->addTask($this->taskExec($this->getFireExecutable() . ' vrt:generate-backstop-config')); - $tasks->addTask($this->taskExec($this->getFireExecutable() . ' vrt:local-env-config')); - + if ($tool === 'backstopjs(deprecated)') { + $tasks->addTask($this->taskExec($this->getFireExecutable() . ' vrt:generate-backstop-config')); + $tasks->addTask($this->taskExec($this->getFireExecutable() . ' vrt:local-env-config')); + } + if ($tool == 'playwright') { + $tasks->addTask($this->taskExec($this->getFireExecutable() . ' vrt:playwright:init')); + } return $tasks; } } From d2dac425b9df9434ad16172251ccd2b240b164c0 Mon Sep 17 00:00:00 2001 From: Rodrigo Espinoza Date: Thu, 28 May 2026 15:42:58 -0600 Subject: [PATCH 2/4] Debuging and reshaping the the vrt init command --- .../{aft_utilities.js => 4k_utilities.js} | 11 ---- .../vrt/{homepage.spec.js => common.spec.js} | 2 +- .../Commands/VrtPlaywrightInitCommand.php | 56 ++++++++----------- 3 files changed, 25 insertions(+), 44 deletions(-) rename assets/templates/playwright/tests/support/{aft_utilities.js => 4k_utilities.js} (78%) rename assets/templates/playwright/tests/vrt/{homepage.spec.js => common.spec.js} (96%) diff --git a/assets/templates/playwright/tests/support/aft_utilities.js b/assets/templates/playwright/tests/support/4k_utilities.js similarity index 78% rename from assets/templates/playwright/tests/support/aft_utilities.js rename to assets/templates/playwright/tests/support/4k_utilities.js index 3a953af..748c69b 100644 --- a/assets/templates/playwright/tests/support/aft_utilities.js +++ b/assets/templates/playwright/tests/support/4k_utilities.js @@ -21,17 +21,6 @@ const forceLoadLazyImages = async (page) => { }) } -const normalizeJoinTheMovementSignup = async (page) => { - const zipCodeField = page.locator('#form-zip_code') - - await zipCodeField.first().waitFor({ state: 'attached', timeout: 10000 }).catch(() => {}) - - await zipCodeField.evaluateAll((fields) => { - fields.forEach((field) => { - field.setAttribute('placeholder', 'Zip') - }) - }) -} // Returns devices profiles for VRT. const vrtDeviceProfiles = (() => { const desktopChromeProfile = { ...devices['Desktop Chrome'] } diff --git a/assets/templates/playwright/tests/vrt/homepage.spec.js b/assets/templates/playwright/tests/vrt/common.spec.js similarity index 96% rename from assets/templates/playwright/tests/vrt/homepage.spec.js rename to assets/templates/playwright/tests/vrt/common.spec.js index 4997af8..34a8f69 100644 --- a/assets/templates/playwright/tests/vrt/homepage.spec.js +++ b/assets/templates/playwright/tests/vrt/common.spec.js @@ -3,7 +3,7 @@ import * as aftUtilities from '../support/aft_utilities' const { test, expect } = require('@playwright/test'); -//test.describe.configure({ mode: 'serial' }); +test.describe.configure({ mode: 'serial' }); const runHomepageVrtTest = async ({ page, screenshotName }) => { diff --git a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php index ae99aa9..1617e65 100644 --- a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php +++ b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php @@ -22,6 +22,7 @@ class VrtPlaywrightInitCommand extends FireCommandBase { * @option $y Run the command with no interection required. */ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { + $env = Robo::config()->get('local_environment'); $projectRoot = $this->getLocalEnvRoot(); $drupalRoot = $this->getDrupalRoot(); $atkHome = getenv('ATK_HOME'); @@ -34,24 +35,21 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { if (!$opts['y']) { $shouldProceed = $io->confirm("This action will generate/update Playwright VRT scaffolding in $testsRoot. Continue?", TRUE); } - + var_dump($shouldProceed . ' proceed'); if (!$shouldProceed) { $io->warning('Playwright VRT init skipped.'); return 0; } - $tasks = $this->collectionBuilder($io); - - $tasks->addTask($this->taskExec("composer require 'drupal/automated_testing_kit'")->dir($projectRoot)); - + // $tasks = $this->collectionBuilder($io); + // $this->taskExec($env . " composer require 'drupal/automated_testing_kit'")->dir($projectRoot)->run(); $atkSetup = $drupalRoot . '/modules/contrib/automated_testing_kit/module_support/atk_setup'; if (!file_exists($atkSetup)) { throw new AbortTasksException("Automated Testing Kit not found at $atkSetup. Install drupal/automated_testing_kit and rerun."); } - - $atkCommand = 'ATK_HOME=' . $atkHome . ' ' . $atkSetup . ' playwright'; - $tasks->addTask($this->taskExec($atkCommand)->dir($projectRoot)); - $tasks->addTask($this->taskExec($atkCommand)->dir($projectRoot)); + $this->taskFilesystemStack()->mkdir($projectRoot . '/' . $atkHome)->run(); + $atkCommand = $atkSetup . ' playwright'; + $this->taskExec($atkCommand)->dir($projectRoot)->run(); $testsDir = $testsRoot . '/tests'; if (is_dir($testsDir)) { @@ -62,28 +60,28 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { if (in_array($folderName, ['support', 'data', 'vrt'], TRUE)) { continue; } + $this->taskFilesystemStack()->remove($testItem)->run(); } - $tasks->addTask($this->taskFilesystemStack()->remove($testItem)); } } if (!is_dir($testsRoot . '/tests/vrt')) { - $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/tests/vrt')); + $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/vrt'); } if (!is_dir($testsRoot . '/tests/support')) { - $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support')); + $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support'); } if (!is_dir($testsRoot . '/css')) { - $tasks->addTask($this->taskFilesystemStack()->mkdir($testsRoot . '/css')); + $this->taskFilesystemStack()->mkdir($testsRoot . '/css'); } $assets = dirname(__DIR__, 4) . '/assets/templates/playwright/'; - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'playwright.config.js', $testsRoot . '/playwright.config.js', TRUE)); - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'playwright.atk.config.js', $testsRoot . '/playwright.atk.config.js', TRUE)); - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'css/screenshotGlobalStyle.css', $testsRoot . '/css/screenshotGlobalStyle.css', TRUE)); - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/support/aft_utilities.js', $testsRoot . '/tests/support/aft_utilities.js', TRUE)); - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/vrt/homepage.spec.js', $testsRoot . '/tests/vrt/homepage.spec.js', TRUE)); - $tasks->addTask($this->taskFilesystemStack()->copy($assets . 'tests/vrt/page_components.spec.js', $testsRoot . '/tests/vrt/page_components.spec.js', TRUE)); + $this->taskFilesystemStack()->copy($assets . 'playwright.config.js', $testsRoot . '/playwright.config.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'playwright.atk.config.js', $testsRoot . '/playwright.atk.config.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'css/screenshotGlobalStyle.css', $testsRoot . '/css/screenshotGlobalStyle.css', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'tests/support/aft_utilities.js', $testsRoot . '/tests/support/aft_utilities.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'tests/vrt/homepage.spec.js', $testsRoot . '/tests/vrt/homepage.spec.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'tests/vrt/page_components.spec.js', $testsRoot . '/tests/vrt/page_components.spec.js', TRUE)->run(); $gitignorePath = $testsRoot . '/.gitignore'; $gitignoreTask = $this->taskWriteToFile($gitignorePath); @@ -100,29 +98,23 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { ->appendUnlessMatches('/\/tests\/support\/loginAuth\.json/', "/tests/support/loginAuth.json\n") ->appendUnlessMatches('/\.env/', ".env\n") ->appendUnlessMatches('/\*\.spec\.js-snapshots/', "*.spec.js-snapshots\n"); - $tasks->addTask($gitignoreTask); - + $gitignoreTask->run(); $defaultBaseUrl = $this->getDefaultBaseUrl($projectRoot); - $tasks->addTask( + $this->taskReplaceInFile($testsRoot . '/playwright.config.js') ->from('__DEFAULT_BASE_URL__') - ->to($defaultBaseUrl) - ); + ->to($defaultBaseUrl); $drushCmd = $this->getDefaultDrushCommand(); $pantheonSite = Robo::config()->get('remote_sitename') ?: 'remote-pantheon-site-machine-name'; - $tasks->addTask( + $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') ->from('__DRUSH_CMD__') - ->to($drushCmd) - ); - $tasks->addTask( + ->to($drushCmd); + $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') ->from('__PANTHEON_SITE__') - ->to($pantheonSite) - ); - - return $tasks; + ->to($pantheonSite); } /** From 0c4c209e9fb6802d9edd7c82c2596f888f13349b Mon Sep 17 00:00:00 2001 From: Rodrigo Espinoza Date: Mon, 8 Jun 2026 15:21:29 -0600 Subject: [PATCH 3/4] multiple improvements --- assets/templates/playwright/.nvmrc | 1 + .../playwright/data/vrtCommonPages.json | 12 ++++ .../templates/playwright/playwright.config.js | 2 +- .../playwright/tests/vrt/common.spec.js | 2 - .../tests/vrt/page_components.spec.js | 63 ------------------- .../Commands/VrtPlaywrightInitCommand.php | 48 ++++++++++---- 6 files changed, 49 insertions(+), 79 deletions(-) create mode 100644 assets/templates/playwright/.nvmrc create mode 100644 assets/templates/playwright/data/vrtCommonPages.json delete mode 100644 assets/templates/playwright/tests/vrt/page_components.spec.js diff --git a/assets/templates/playwright/.nvmrc b/assets/templates/playwright/.nvmrc new file mode 100644 index 0000000..5bf4400 --- /dev/null +++ b/assets/templates/playwright/.nvmrc @@ -0,0 +1 @@ +24.15.0 diff --git a/assets/templates/playwright/data/vrtCommonPages.json b/assets/templates/playwright/data/vrtCommonPages.json new file mode 100644 index 0000000..470329a --- /dev/null +++ b/assets/templates/playwright/data/vrtCommonPages.json @@ -0,0 +1,12 @@ +[ + { + "name": "Home", + "path": "/", + "screenshotName": "home-page" + }, + { + "name": "about-us", + "path": "/about-us", + "screenshotName": "about-us" + } +] diff --git a/assets/templates/playwright/playwright.config.js b/assets/templates/playwright/playwright.config.js index bfcce0e..c436b51 100644 --- a/assets/templates/playwright/playwright.config.js +++ b/assets/templates/playwright/playwright.config.js @@ -55,7 +55,7 @@ module.exports = defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'], - userAgent: 'aft-main-playwright-ci/1.0' + userAgent: 'my-site-playwright-ci/1.0' }, }, diff --git a/assets/templates/playwright/tests/vrt/common.spec.js b/assets/templates/playwright/tests/vrt/common.spec.js index 34a8f69..6c4cd58 100644 --- a/assets/templates/playwright/tests/vrt/common.spec.js +++ b/assets/templates/playwright/tests/vrt/common.spec.js @@ -3,8 +3,6 @@ import * as aftUtilities from '../support/aft_utilities' const { test, expect } = require('@playwright/test'); -test.describe.configure({ mode: 'serial' }); - const runHomepageVrtTest = async ({ page, screenshotName }) => { await page.goto('/'); diff --git a/assets/templates/playwright/tests/vrt/page_components.spec.js b/assets/templates/playwright/tests/vrt/page_components.spec.js deleted file mode 100644 index 52da60c..0000000 --- a/assets/templates/playwright/tests/vrt/page_components.spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import * as atkCommands from '../support/atk_commands' -import * as aftUtilities from '../support/aft_utilities' - - -const { test, expect } = require('@playwright/test'); - -//test.describe.configure({ mode: 'serial' }); - -const runComponentsVrtTest = async ({ page, context, screenshotName }) => { - await atkCommands.logInViaUli(page, context, 1) - await page.goto('/vrt-components-test-dont-delete'); - // Force-load lazy images. - await aftUtilities.forceLoadLazyImages(page); - await aftUtilities.normalizeJoinTheMovementSignup(page); - await page.waitForLoadState('networkidle'); - - await page.addStyleTag({ - content: ` - * { - animation: none !important; - transition: none !important; - } - ` - }); - - await expect(page).toHaveScreenshot(screenshotName, { fullPage: true }); -} - -test.describe('Components page VRT - Desktop', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('desktopChrome') }); - - test('Components page VRT @vrt', async ({ page, context }) => { - await runComponentsVrtTest({ - page, - context, - screenshotName: 'components-desktop.png', - }); - }); -}); - -test.describe('Components page VRT - iPad', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('iPad') }); - - test('Components page VRT @vrt', async ({ page, context }) => { - await runComponentsVrtTest({ - page, - context, - screenshotName: 'components-ipad.png', - }); - }); -}); - -test.describe('Components page VRT - iPhone 12', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('iPhone12') }); - - test('Components page VRT @vrt', async ({ page, context }) => { - await runComponentsVrtTest({ - page, - context, - screenshotName: 'components-iphone-12.png', - }); - }); -}); diff --git a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php index 1617e65..267b4aa 100644 --- a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php +++ b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php @@ -40,9 +40,8 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { $io->warning('Playwright VRT init skipped.'); return 0; } - // $tasks = $this->collectionBuilder($io); - // $this->taskExec($env . " composer require 'drupal/automated_testing_kit'")->dir($projectRoot)->run(); + $this->taskExec($env . " composer require 'drupal/automated_testing_kit' 'drupal/qa_accounts:^1.1'")->dir($projectRoot)->run(); $atkSetup = $drupalRoot . '/modules/contrib/automated_testing_kit/module_support/atk_setup'; if (!file_exists($atkSetup)) { throw new AbortTasksException("Automated Testing Kit not found at $atkSetup. Install drupal/automated_testing_kit and rerun."); @@ -50,8 +49,9 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { $this->taskFilesystemStack()->mkdir($projectRoot . '/' . $atkHome)->run(); $atkCommand = $atkSetup . ' playwright'; $this->taskExec($atkCommand)->dir($projectRoot)->run(); - $testsDir = $testsRoot . '/tests'; + // Removing not needed test files and folders generated by ATK, but + // keeping support, data, and vrt folders if they exist. if (is_dir($testsDir)) { $testItems = glob($testsDir . '/*') ?: []; foreach ($testItems as $testItem) { @@ -64,24 +64,27 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { } } } - + // Enabing ATK and QA accounts modules in Drupal to ensure the test + //environment is ready. + $this->taskExec($env . ' drush en automated_testing_kit qa_accounts -y')->dir($projectRoot)->run(); if (!is_dir($testsRoot . '/tests/vrt')) { - $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/vrt'); + $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/vrt')->run(); } if (!is_dir($testsRoot . '/tests/support')) { - $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support'); + $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support')->run(); } if (!is_dir($testsRoot . '/css')) { - $this->taskFilesystemStack()->mkdir($testsRoot . '/css'); + $this->taskFilesystemStack()->mkdir($testsRoot . '/css')->run(); } $assets = dirname(__DIR__, 4) . '/assets/templates/playwright/'; $this->taskFilesystemStack()->copy($assets . 'playwright.config.js', $testsRoot . '/playwright.config.js', TRUE)->run(); $this->taskFilesystemStack()->copy($assets . 'playwright.atk.config.js', $testsRoot . '/playwright.atk.config.js', TRUE)->run(); $this->taskFilesystemStack()->copy($assets . 'css/screenshotGlobalStyle.css', $testsRoot . '/css/screenshotGlobalStyle.css', TRUE)->run(); - $this->taskFilesystemStack()->copy($assets . 'tests/support/aft_utilities.js', $testsRoot . '/tests/support/aft_utilities.js', TRUE)->run(); - $this->taskFilesystemStack()->copy($assets . 'tests/vrt/homepage.spec.js', $testsRoot . '/tests/vrt/homepage.spec.js', TRUE)->run(); - $this->taskFilesystemStack()->copy($assets . 'tests/vrt/page_components.spec.js', $testsRoot . '/tests/vrt/page_components.spec.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'tests/support/4k_utilities.js', $testsRoot . '/tests/support/4k_utilities.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'tests/vrt/common.spec.js', $testsRoot . '/tests/vrt/common.spec.js', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . 'data/vrtCommonPages.json', $testsRoot . '/tests/data/vrtCommonPages.json', TRUE)->run(); + $this->taskFilesystemStack()->copy($assets . '.nvmrc', $testsRoot . '/.nvmrc', TRUE)->run(); $gitignorePath = $testsRoot . '/.gitignore'; $gitignoreTask = $this->taskWriteToFile($gitignorePath); @@ -99,22 +102,41 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { ->appendUnlessMatches('/\.env/', ".env\n") ->appendUnlessMatches('/\*\.spec\.js-snapshots/', "*.spec.js-snapshots\n"); $gitignoreTask->run(); + + // Updating npm packages and installing Playwright browsers && 4k vrt helper + // package. + if (getenv('NVM_DIR') && file_exists($testsRoot . '/.nvmrc')) { + var_dump('IN NVM'); + $command = 'export NVM_DIR=$HOME/.nvm && . $NVM_DIR/nvm.sh && cd ' . $testsRoot . ' && nvm install && npm install --no-audit --no-fund'; + // $this->taskExec('nvm use')->dir($testsRoot)->run(); + // $this->taskExec('npm install')->dir($testsRoot)->run(); + // $this->taskExec('npm install @fkbender/playwright-vrt-scripts')->dir($testsRoot)->run(); + $this->taskExec($command)->ignoreReturnValue()->run(); + + } + else { + var_dump('NOT NVM'); + $this->taskExec($env . ' npm install')->dir($testsRoot)->run(); + $this->taskExec($env . ' npm install @fkbender/playwright-vrt-scripts')->dir($testsRoot)->run(); + } + $this->taskExec('npx playwright install --with-deps')->dir($testsRoot . '/playwright')->run(); + $defaultBaseUrl = $this->getDefaultBaseUrl($projectRoot); $this->taskReplaceInFile($testsRoot . '/playwright.config.js') ->from('__DEFAULT_BASE_URL__') - ->to($defaultBaseUrl); + ->to($defaultBaseUrl)->run(); $drushCmd = $this->getDefaultDrushCommand(); $pantheonSite = Robo::config()->get('remote_sitename') ?: 'remote-pantheon-site-machine-name'; $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') ->from('__DRUSH_CMD__') - ->to($drushCmd); + ->to($drushCmd)->run(); $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') ->from('__PANTHEON_SITE__') - ->to($pantheonSite); + ->to($pantheonSite)->run(); } /** From 4388bc3f24706a4469efa410331e77a969432872 Mon Sep 17 00:00:00 2001 From: Rodrigo Espinoza Date: Tue, 16 Jun 2026 09:57:01 -0600 Subject: [PATCH 4/4] Final details for Playwright + fire integration --- README.md | 79 +++++++++++- .../playwright/playwright.atk.config.js | 6 +- .../playwright/tests/support/4k_utilities.js | 1 - .../playwright/tests/vrt/common.spec.js | 73 ++++++----- src/Robo/Plugin/Commands/VrtBase.php | 61 ++++++--- .../Commands/VrtPlaywrightInitCommand.php | 121 ++++++++++++++++-- .../Plugin/Commands/VrtReferenceCommand.php | 8 +- src/Robo/Plugin/Commands/VrtRunCommand.php | 10 +- 8 files changed, 284 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 45ddcc5..b0e88df 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,10 @@ Example: Alias: `vlec` + - `vrt:playwright:init`: Configure Playwright VRT scaffolding using Automated Testing Kit. + + Alias: `vpinit` + - `vrt:reference`: Takes new reference screeshots from the reference URL. Alias: `vref` @@ -210,11 +214,84 @@ Example: **2. Full:** It replaces all existing code and allows you to write the command from scratch. You can also create a new command, just choice the "Custom" option at the prompt when it ask you for for the command you want to overwrite, then respond to the questions, now a new command should have been created in the custom path, by default only a task is added to cleans the Drupal cache, but from this file, you can add your custom tasks. - + - `platform:uli`: This command allows you to generate a one-time login URL for any environment hosted on Pantheon, Acquia, or Platform.sh. Alias: `puli` +## Playwright VRT + +FIRE supports Playwright-based visual regression testing as an alternative to the deprecated Backstop workflow. + +### Initialize Playwright VRT + +Set `ATK_HOME` to the Playwright test root before running the init command: + +``` +export ATK_HOME=./tests/playwright +fire vrt:playwright:init +``` + +The init command installs the required Drupal packages, runs the Automated Testing Kit Playwright scaffold, copies FIRE's Playwright templates, installs Node dependencies, installs `@fkbender/playwright-vrt-scripts`, installs Playwright browsers, and adds these npm scripts to `tests/playwright/package.json`: + +``` +"scripts": { + "vrt": "playwright-vrt", + "vrt:local": "playwright-vrt-local", + "vrt:ci": "playwright-vrt-ci" +} +``` + +If `NVM_DIR` is set and `tests/playwright/.nvmrc` exists, FIRE sources NVM and runs the npm commands using the Node version defined in `.nvmrc`. + +### Environment Settings + +During initialization, FIRE asks for: + +- Baseline URL +- Baseline Terminus env +- Baseline Terminus site +- Candidate URL, usually the local URL + +Those values are written to `tests/playwright/.env`: + +``` +BASELINE_URL="https://www.aft.org" +CANDIDATE_URL="http://aft-main.lndo.site" +BASELINE_TERMINUS_ENV=live +BASELINE_TERMINUS_SITE=aft-main +``` + +The `.env` file is added to `tests/playwright/.gitignore` by the init command. + +### Run Playwright VRT + +After initialization, run Playwright VRT with: + +``` +fire vrt:run --tool=playwright +``` + +or from the Playwright test root: + +``` +cd tests/playwright +npm run vrt +``` + +`fire vrt:reference --tool=playwright` also runs the Playwright VRT npm script from `tests/playwright`. + +### Automatic Tool Detection + +`vrt:run` and `vrt:reference` default to `--tool=auto`. + +FIRE detects configured tools using these files: + +- Backstop: `tests/backstop/backstop.json` or `tests/backstop/backstop-local.json` +- Playwright: `tests/playwright/package.json` or `tests/playwright/playwright.config.js` + +If only one tool is configured, FIRE uses it automatically. If both Backstop and Playwright are configured in an interactive shell, FIRE asks which one to run. In non-interactive runs with both configured, FIRE defaults to Playwright. + ## Configuration Into your project root create a file called: `fire.yml` and iside of it speficify your global project settings. diff --git a/assets/templates/playwright/playwright.atk.config.js b/assets/templates/playwright/playwright.atk.config.js index 8d10580..6f759ae 100644 --- a/assets/templates/playwright/playwright.atk.config.js +++ b/assets/templates/playwright/playwright.atk.config.js @@ -10,7 +10,7 @@ if (currentEnv != 'local') { module.exports = { operatingMode: "native", drushCmd: "__DRUSH_CMD__", - articleAddUrl: 'node/add/news_article', + articleAddUrl: 'node/add/article', contactUsUrl: "form/contact", logInUrl: "user/login", logOutUrl: "user/logout", @@ -27,10 +27,10 @@ module.exports = { pageAddUrl: 'node/add/page', registerUrl: "user/register", resetPasswordUrl: "user/password", - termAddUrl: 'admin/structure/taxonomy/manage/file_categories/add', + termAddUrl: 'admin/structure/taxonomy/manage/terms/add', termEditUrl: 'taxonomy/term/{tid}/edit', termDeleteUrl: 'taxonomy/term/{tid}/delete', - termListUrl: 'admin/structure/taxonomy/manage/file_categories/overview', + termListUrl: 'admin/structure/taxonomy/manage/terms/overview', termViewUrl: 'taxonomy/term/{tid}', xmlSitemapUrl: 'admin/config/search/simplesitemap', authDir: "tests/support", diff --git a/assets/templates/playwright/tests/support/4k_utilities.js b/assets/templates/playwright/tests/support/4k_utilities.js index 748c69b..177c13a 100644 --- a/assets/templates/playwright/tests/support/4k_utilities.js +++ b/assets/templates/playwright/tests/support/4k_utilities.js @@ -43,5 +43,4 @@ const getVrtDeviceProfile = (key) => vrtDeviceProfiles[key] export { forceLoadLazyImages, getVrtDeviceProfile, - normalizeJoinTheMovementSignup, } diff --git a/assets/templates/playwright/tests/vrt/common.spec.js b/assets/templates/playwright/tests/vrt/common.spec.js index 6c4cd58..5f11d46 100644 --- a/assets/templates/playwright/tests/vrt/common.spec.js +++ b/assets/templates/playwright/tests/vrt/common.spec.js @@ -1,11 +1,13 @@ -import * as atkCommands from '../support/atk_commands' -import * as aftUtilities from '../support/aft_utilities' +import * as aftUtilities from '../support/4k_utilities' +import commonPages from '../data/vrtCommonPages.json' const { test, expect } = require('@playwright/test'); -const runHomepageVrtTest = async ({ page, screenshotName }) => { +//test.describe.configure({ mode: 'serial' }); - await page.goto('/'); +const runCommonVrtTest = async ({ page, path, screenshotName }) => { + + await page.goto(path); // Force-load lazy images. await aftUtilities.forceLoadLazyImages(page); await page.waitForLoadState('networkidle'); @@ -22,35 +24,36 @@ const runHomepageVrtTest = async ({ page, screenshotName }) => { await expect(page).toHaveScreenshot(screenshotName, { fullPage: true }); } -test.describe('Homepage VRT - Desktop', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('desktopChrome') }); - - test('Homepage VRT - desktop @vrt', async ({ page }) => { - await runHomepageVrtTest({ - page, - screenshotName: 'homepage-desktop.png', - }); - }); -}); - -test.describe('Homepage VRT - iPad', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('iPad') }); - - test('Homepage VRT - ipad @vrt', async ({ page }) => { - await runHomepageVrtTest({ - page, - screenshotName: 'homepage-ipad.png', - }); +const deviceProfiles = [ + { + title: 'Desktop', + profile: 'desktopChrome', + screenshotSuffix: 'desktop', + }, + { + title: 'iPad', + profile: 'iPad', + screenshotSuffix: 'ipad', + }, + { + title: 'iPhone 12', + profile: 'iPhone12', + screenshotSuffix: 'iphone-12', + }, +] + +for (const device of deviceProfiles) { + test.describe(`Common VRT - ${device.title}`, () => { + test.use({ ...aftUtilities.getVrtDeviceProfile(device.profile) }); + + for (const commonPage of commonPages) { + test(`Common VRT - ${commonPage.name} - ${device.screenshotSuffix} @vrt`, async ({ page }) => { + await runCommonVrtTest({ + page, + path: commonPage.path, + screenshotName: `${commonPage.screenshotName}-${device.screenshotSuffix}.png`, + }); + }); + } }); -}); - -test.describe('Homepage VRT - iPhone 12', () => { - test.use({ ...aftUtilities.getVrtDeviceProfile('iPhone12') }); - - test('Homepage VRT - iphone @vrt', async ({ page }) => { - await runHomepageVrtTest({ - page, - screenshotName: 'homepage-iphone-12.png', - }); - }); -}); +} diff --git a/src/Robo/Plugin/Commands/VrtBase.php b/src/Robo/Plugin/Commands/VrtBase.php index eec6ea4..1c8a4bc 100644 --- a/src/Robo/Plugin/Commands/VrtBase.php +++ b/src/Robo/Plugin/Commands/VrtBase.php @@ -22,6 +22,49 @@ class VrtBase extends FireCommandBase { */ protected $io; + /** + * Resolve which VRT tool should handle the command. + */ + protected function resolveVrtTool(array $opts, ConsoleIO $io) { + $tool = strtolower((string) ($opts['tool'] ?? 'auto')); + if (in_array($tool, ['backstopjs', 'backstopjs(deprecated)'], TRUE)) { + $tool = 'backstop'; + } + + $root = $this->getLocalEnvRoot(); + $hasBackstop = file_exists($root . '/tests/backstop/backstop.json') || file_exists($root . '/tests/backstop/backstop-local.json'); + $hasPlaywright = file_exists($root . '/tests/playwright/package.json') || file_exists($root . '/tests/playwright/playwright.config.js'); + + if (in_array($tool, ['backstop', 'playwright'], TRUE)) { + if ($tool === 'backstop' && !$hasBackstop) { + throw new AbortTasksException('Backstop VRT config was not found. Run fire vrt:init and choose Backstop first.'); + } + if ($tool === 'playwright' && !$hasPlaywright) { + throw new AbortTasksException('Playwright VRT config was not found. Run fire vrt:playwright:init first.'); + } + return $tool; + } + + if ($tool !== 'auto') { + throw new AbortTasksException("Invalid VRT tool '$tool'. Use backstop, playwright, or auto."); + } + + if ($hasBackstop && $hasPlaywright) { + if (Robo::config()->isInteractive()) { + return $io->choice('Both Backstop and Playwright VRT are configured. Which tool do you want to run?', ['playwright', 'backstop'], 0); + } + return 'playwright'; + } + if ($hasPlaywright) { + return 'playwright'; + } + if ($hasBackstop) { + return 'backstop'; + } + + throw new AbortTasksException('No VRT configuration was found. Run fire vrt:init first.'); + } + /** * Similar to taskExec(), but for Backstop commands. * @@ -66,24 +109,6 @@ protected function backstopTaskExec(ConsoleIO $io, string $backstopCommand) { } } - /** - * Similar to taskExec(), but for Playwright commands. - * - * @param ConsoleIO $io - * @param string $playwrightCommand - * E.g. 'test --grep @vrt' - * - * @return \Robo\Collection\CollectionBuilder|\Robo\Task\Base\Exec - */ - protected function playwrightTaskExec(ConsoleIO $io, string $playwrightCommand) { - $this->io = $io; - $playwrightRoot = $this->getLocalEnvRoot() . '/tests/playwright'; - if (!is_dir($playwrightRoot)) { - throw new AbortTasksException("Playwright tests folder not found at $playwrightRoot. Run 'fire vrt:playwright:init' first."); - } - return $this->taskExec('npx playwright ' . $playwrightCommand)->dir($playwrightRoot); - } - /** * Regenerate cookies for this hosting environment, if there's a script for it. * diff --git a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php index 267b4aa..1f34575 100644 --- a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php +++ b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php @@ -35,11 +35,11 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { if (!$opts['y']) { $shouldProceed = $io->confirm("This action will generate/update Playwright VRT scaffolding in $testsRoot. Continue?", TRUE); } - var_dump($shouldProceed . ' proceed'); if (!$shouldProceed) { $io->warning('Playwright VRT init skipped.'); return 0; } + $vrtEnv = $this->collectVrtEnvConfig($io, $projectRoot); // $tasks = $this->collectionBuilder($io); $this->taskExec($env . " composer require 'drupal/automated_testing_kit' 'drupal/qa_accounts:^1.1'")->dir($projectRoot)->run(); $atkSetup = $drupalRoot . '/modules/contrib/automated_testing_kit/module_support/atk_setup'; @@ -102,24 +102,32 @@ public function vrtPlaywrightInit(ConsoleIO $io, $opts = ['y|y' => FALSE]) { ->appendUnlessMatches('/\.env/', ".env\n") ->appendUnlessMatches('/\*\.spec\.js-snapshots/', "*.spec.js-snapshots\n"); $gitignoreTask->run(); + $this->writeVrtEnvFile($testsRoot, $vrtEnv); // Updating npm packages and installing Playwright browsers && 4k vrt helper // package. - if (getenv('NVM_DIR') && file_exists($testsRoot . '/.nvmrc')) { - var_dump('IN NVM'); - $command = 'export NVM_DIR=$HOME/.nvm && . $NVM_DIR/nvm.sh && cd ' . $testsRoot . ' && nvm install && npm install --no-audit --no-fund'; - // $this->taskExec('nvm use')->dir($testsRoot)->run(); - // $this->taskExec('npm install')->dir($testsRoot)->run(); - // $this->taskExec('npm install @fkbender/playwright-vrt-scripts')->dir($testsRoot)->run(); - $this->taskExec($command)->ignoreReturnValue()->run(); + $nvmDir = getenv('NVM_DIR'); + if ($nvmDir && file_exists($testsRoot . '/.nvmrc')) { + $nvmScript = rtrim($nvmDir, '/') . '/nvm.sh'; + if (!file_exists($nvmScript)) { + throw new AbortTasksException("NVM script not found at $nvmScript."); + } + $command = 'export NVM_DIR=' . escapeshellarg($nvmDir) + . ' && . ' . escapeshellarg($nvmScript) + . ' && cd ' . escapeshellarg($testsRoot) + . ' && nvm install' + . ' && npm install --no-audit --no-fund' + . ' && npm install --no-audit --no-fund @fkbender/playwright-vrt-scripts' + . ' && npx playwright install --with-deps'; + $this->taskExec($command)->run(); } else { - var_dump('NOT NVM'); $this->taskExec($env . ' npm install')->dir($testsRoot)->run(); $this->taskExec($env . ' npm install @fkbender/playwright-vrt-scripts')->dir($testsRoot)->run(); + $this->taskExec($env . ' npx playwright install --with-deps')->dir($testsRoot)->run(); } - $this->taskExec('npx playwright install --with-deps')->dir($testsRoot . '/playwright')->run(); + $this->addVrtScriptsToPackageJson($testsRoot); $defaultBaseUrl = $this->getDefaultBaseUrl($projectRoot); @@ -154,6 +162,66 @@ private function resolveAtkHomePath(string $projectRoot, string $atkHome) { return rtrim($projectRoot, '/') . '/' . $relative; } + /** + * Collect values for the Playwright VRT .env file. + */ + private function collectVrtEnvConfig(ConsoleIO $io, string $projectRoot) { + $baselineTerminusEnv = Robo::config()->get('remote_canonical_env') ?: 'live'; + $baselineTerminusSite = Robo::config()->get('remote_sitename') ?: ''; + $baselineUrl = $this->getDefaultBaselineUrl($baselineTerminusEnv, $baselineTerminusSite); + $candidateUrl = $this->getDefaultBaseUrl($projectRoot); + + $baselineUrl = $io->ask('Please enter the baseline Url', $baselineUrl); + $baselineTerminusEnv = $io->ask('Please enter baseline Terminus env', $baselineTerminusEnv); + $baselineTerminusSite = $io->ask('Please enter baseline Terminus Site', $baselineTerminusSite); + $candidateUrl = $io->ask('Please enter The candidate url(local)', $candidateUrl); + + return [ + 'baseline_url' => trim((string) $baselineUrl), + 'candidate_url' => trim((string) $candidateUrl), + 'baseline_terminus_env' => trim((string) $baselineTerminusEnv), + 'baseline_terminus_site' => trim((string) $baselineTerminusSite), + ]; + } + + /** + * Write Playwright VRT environment variables to tests/playwright/.env. + */ + private function writeVrtEnvFile(string $testsRoot, array $vrtEnv) { + $contents = implode("\n", [ + 'BASELINE_URL="' . $this->escapeDotEnvDoubleQuotedValue($vrtEnv['baseline_url']) . '"', + 'CANDIDATE_URL="' . $this->escapeDotEnvDoubleQuotedValue($vrtEnv['candidate_url']) . '"', + 'BASELINE_TERMINUS_ENV=' . $vrtEnv['baseline_terminus_env'], + 'BASELINE_TERMINUS_SITE=' . $vrtEnv['baseline_terminus_site'], + ]) . "\n"; + + $this->taskWriteToFile($testsRoot . '/.env') + ->text($contents) + ->run(); + } + + /** + * Escape a value for use in a double-quoted dotenv assignment. + */ + private function escapeDotEnvDoubleQuotedValue(string $value) { + return str_replace( + ["\\", '"', "\n", "\r"], + ["\\\\", '\\"', '\\n', ''], + $value + ); + } + + /** + * Infer a baseline URL from remote Terminus config when available. + */ + private function getDefaultBaselineUrl(string $baselineTerminusEnv, string $baselineTerminusSite) { + if (Robo::config()->get('remote_platform') === 'pantheon' && $baselineTerminusEnv && $baselineTerminusSite) { + return 'https://' . $baselineTerminusEnv . '-' . $baselineTerminusSite . '.pantheonsite.io'; + } + + return ''; + } + /** * Infer the best default base URL from local env config. */ @@ -192,4 +260,37 @@ private function getDefaultDrushCommand() { return 'drush'; } + /** + * Add VRT npm scripts to the generated Playwright package.json. + */ + private function addVrtScriptsToPackageJson(string $testsRoot) { + $packageJsonPath = $testsRoot . '/package.json'; + if (!file_exists($packageJsonPath)) { + throw new AbortTasksException("Package file not found at $packageJsonPath."); + } + + $packageJson = json_decode(file_get_contents($packageJsonPath), TRUE); + if (!is_array($packageJson)) { + throw new AbortTasksException("Unable to parse $packageJsonPath."); + } + if (isset($packageJson['scripts']) && !is_array($packageJson['scripts'])) { + throw new AbortTasksException("Invalid scripts section in $packageJsonPath."); + } + + $packageJson['scripts'] = array_merge($packageJson['scripts'] ?? [], [ + 'vrt' => 'playwright-vrt', + 'vrt:local' => 'playwright-vrt-local', + 'vrt:ci' => 'playwright-vrt-ci', + ]); + + $packageJson = json_encode($packageJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($packageJson === FALSE) { + throw new AbortTasksException("Unable to encode $packageJsonPath."); + } + + $this->taskWriteToFile($packageJsonPath) + ->text($packageJson . "\n") + ->run(); + } + } diff --git a/src/Robo/Plugin/Commands/VrtReferenceCommand.php b/src/Robo/Plugin/Commands/VrtReferenceCommand.php index 8b06ed3..98c9b63 100644 --- a/src/Robo/Plugin/Commands/VrtReferenceCommand.php +++ b/src/Robo/Plugin/Commands/VrtReferenceCommand.php @@ -16,13 +16,15 @@ class VrtReferenceCommand extends VrtBase { * * @command vrt:reference * @aliases vref - * @option $tool Choose backstop or playwright (default: backstop). + * @option $tool Choose auto, backstop, or playwright (default: auto). * */ - public function vrtReference(ConsoleIO $io, array $args, $opts = ['tool' => 'backstop']) { + public function vrtReference(ConsoleIO $io, array $args, $opts = ['tool' => 'auto']) { $tool = $this->resolveVrtTool($opts, $io); if ($tool === 'playwright') { - return $this->playwrightTaskExec($io, 'test --grep @vrt --update-snapshots')->run(); + return $this->taskExec('npm run vrt') + ->dir($this->getLocalEnvRoot() . '/tests/playwright') + ->run(); } return $this->backstopTaskExec($io, 'reference')->run(); } diff --git a/src/Robo/Plugin/Commands/VrtRunCommand.php b/src/Robo/Plugin/Commands/VrtRunCommand.php index c4a03e0..4343b02 100644 --- a/src/Robo/Plugin/Commands/VrtRunCommand.php +++ b/src/Robo/Plugin/Commands/VrtRunCommand.php @@ -18,13 +18,15 @@ class VrtRunCommand extends VrtBase { * * @command vrt:run * @aliases vrun - * @option $tool Choose backstop or playwright (default: backstop). + * @option $tool Choose auto, backstop, or playwright (default: auto). * */ - public function vrtRun(ConsoleIO $io, $opts = ['tool' => 'backstop']) { + public function vrtRun(ConsoleIO $io, $opts = ['tool' => 'auto']) { $tool = $this->resolveVrtTool($opts, $io); if ($tool === 'playwright') { - return $this->playwrightTaskExec($io, 'test --grep @vrt')->run(); + return $this->taskExec('npm run vrt') + ->dir($this->getLocalEnvRoot() . '/tests/playwright') + ->run(); } $env = Robo::config()->get('local_environment'); @@ -50,5 +52,5 @@ public function vrtRun(ConsoleIO $io, $opts = ['tool' => 'backstop']) { $this->taskOpenBrowser('https://' . $ddevConfig['name']. '.ddev.site/backstop_data/html_report/index.html')->run(); } } - + }