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/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/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/.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/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/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.atk.config.js b/assets/templates/playwright/playwright.atk.config.js new file mode 100644 index 0000000..6f759ae --- /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/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/terms/add', + termEditUrl: 'taxonomy/term/{tid}/edit', + termDeleteUrl: 'taxonomy/term/{tid}/delete', + termListUrl: 'admin/structure/taxonomy/manage/terms/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..c436b51 --- /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: 'my-site-playwright-ci/1.0' + }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], +}); diff --git a/assets/templates/playwright/tests/support/4k_utilities.js b/assets/templates/playwright/tests/support/4k_utilities.js new file mode 100644 index 0000000..177c13a --- /dev/null +++ b/assets/templates/playwright/tests/support/4k_utilities.js @@ -0,0 +1,46 @@ +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. + } + } + }) +} + +// 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, +} diff --git a/assets/templates/playwright/tests/vrt/common.spec.js b/assets/templates/playwright/tests/vrt/common.spec.js new file mode 100644 index 0000000..5f11d46 --- /dev/null +++ b/assets/templates/playwright/tests/vrt/common.spec.js @@ -0,0 +1,59 @@ +import * as aftUtilities from '../support/4k_utilities' +import commonPages from '../data/vrtCommonPages.json' + +const { test, expect } = require('@playwright/test'); + +//test.describe.configure({ mode: 'serial' }); + +const runCommonVrtTest = async ({ page, path, screenshotName }) => { + + await page.goto(path); + // 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 }); +} + +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`, + }); + }); + } + }); +} diff --git a/src/Robo/Plugin/Commands/VrtBase.php b/src/Robo/Plugin/Commands/VrtBase.php index c236f13..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. * diff --git a/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php new file mode 100644 index 0000000..1f34575 --- /dev/null +++ b/src/Robo/Plugin/Commands/VrtPlaywrightInitCommand.php @@ -0,0 +1,296 @@ + FALSE]) { + $env = Robo::config()->get('local_environment'); + $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; + } + $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'; + if (!file_exists($atkSetup)) { + throw new AbortTasksException("Automated Testing Kit not found at $atkSetup. Install drupal/automated_testing_kit and rerun."); + } + $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) { + if (is_dir($testItem)) { + $folderName = basename($testItem); + if (in_array($folderName, ['support', 'data', 'vrt'], TRUE)) { + continue; + } + $this->taskFilesystemStack()->remove($testItem)->run(); + } + } + } + // 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')->run(); + } + if (!is_dir($testsRoot . '/tests/support')) { + $this->taskFilesystemStack()->mkdir($testsRoot . '/tests/support')->run(); + } + if (!is_dir($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/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); + 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"); + $gitignoreTask->run(); + $this->writeVrtEnvFile($testsRoot, $vrtEnv); + + // Updating npm packages and installing Playwright browsers && 4k vrt helper + // package. + $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 { + $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->addVrtScriptsToPackageJson($testsRoot); + + $defaultBaseUrl = $this->getDefaultBaseUrl($projectRoot); + + $this->taskReplaceInFile($testsRoot . '/playwright.config.js') + ->from('__DEFAULT_BASE_URL__') + ->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)->run(); + + $this->taskReplaceInFile($testsRoot . '/playwright.atk.config.js') + ->from('__PANTHEON_SITE__') + ->to($pantheonSite)->run(); + } + + /** + * 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; + } + + /** + * 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. + */ + 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'; + } + + /** + * 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 bd33227..98c9b63 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,16 @@ class VrtReferenceCommand extends VrtBase { * * @command vrt:reference * @aliases vref + * @option $tool Choose auto, backstop, or playwright (default: auto). * */ - public function vrtReference(ConsoleIO $io, array $args) { + public function vrtReference(ConsoleIO $io, array $args, $opts = ['tool' => 'auto']) { + $tool = $this->resolveVrtTool($opts, $io); + if ($tool === 'playwright') { + 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 eddb6f8..4343b02 100644 --- a/src/Robo/Plugin/Commands/VrtRunCommand.php +++ b/src/Robo/Plugin/Commands/VrtRunCommand.php @@ -18,9 +18,17 @@ class VrtRunCommand extends VrtBase { * * @command vrt:run * @aliases vrun + * @option $tool Choose auto, backstop, or playwright (default: auto). * */ - public function vrtRun(ConsoleIO $io) { + public function vrtRun(ConsoleIO $io, $opts = ['tool' => 'auto']) { + $tool = $this->resolveVrtTool($opts, $io); + if ($tool === 'playwright') { + return $this->taskExec('npm run vrt') + ->dir($this->getLocalEnvRoot() . '/tests/playwright') + ->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 +42,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(); @@ -44,5 +52,5 @@ public function vrtRun(ConsoleIO $io) { $this->taskOpenBrowser('https://' . $ddevConfig['name']. '.ddev.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; } }