diff --git a/.circleci/config.yml b/.circleci/config.yml index aac48843d0..d02239c60c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -198,7 +198,6 @@ jobs: - run: name: Lint module code with NodeJS linters command: docker compose exec -T cli bash -c "npm run lint" || [ "${VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE:-0}" -eq 1 ] - #;> TOOL_ESLINT_STYLELINT #;< DRUPAL_THEME - run: @@ -207,6 +206,7 @@ jobs: [ "${VORTEX_FRONTEND_BUILD_SKIP:-0}" -eq 1 ] && exit 0 docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" || [ "${VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE:-0}" -eq 1 ] #;> DRUPAL_THEME + #;> TOOL_ESLINT_STYLELINT # Audit job runs in its own workflow, independently of the commit workflow. audit: diff --git a/.github/workflows/build-test-deploy.yml b/.github/workflows/build-test-deploy.yml index 917bc8c418..4839252546 100644 --- a/.github/workflows/build-test-deploy.yml +++ b/.github/workflows/build-test-deploy.yml @@ -191,7 +191,6 @@ jobs: - name: Lint module code with NodeJS linters run: docker compose exec -T cli bash -c "npm run lint" continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} - #;> TOOL_ESLINT_STYLELINT #;< DRUPAL_THEME - name: Lint theme code with NodeJS linters @@ -199,6 +198,7 @@ jobs: run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} #;> DRUPAL_THEME + #;> TOOL_ESLINT_STYLELINT #;< !PROVISION_TYPE_PROFILE database: diff --git a/.vortex/installer/src/Prompts/Handlers/Tools.php b/.vortex/installer/src/Prompts/Handlers/Tools.php index 2020b04263..d910f21dc2 100644 --- a/.vortex/installer/src/Prompts/Handlers/Tools.php +++ b/.vortex/installer/src/Prompts/Handlers/Tools.php @@ -8,6 +8,7 @@ use AlexSkrypnyk\File\Replacer\Replacement; use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\JsonManipulator; +use DrevOps\VortexInstaller\Utils\NpmLock; use DrevOps\VortexInstaller\Utils\Strings; use DrevOps\VortexInstaller\Utils\Yaml; use function iter\flatten; @@ -154,9 +155,7 @@ protected function processTool(string $name): void { JsonManipulator::updateFile($this->tmpDir . '/composer.json', $tool['composer.json']); } - if (isset($tool['package.json']) && is_callable($tool['package.json'])) { - JsonManipulator::updateFile($this->tmpDir . '/package.json', $tool['package.json']); - } + $this->processNpmManifests($tool); if (isset($tool['ahoy'])) { foreach ($tool['ahoy'] as $string) { @@ -167,10 +166,57 @@ protected function processTool(string $name): void { } } + $this->processContent($tool); + + File::removeTokenAsync('TOOL_' . strtoupper($name)); + } + + protected function processGroup(string $name): void { + $config = self::getToolDefinitions('groups')[$name]; + $selected_tools = $this->getResponseAsArray(); + + if (!isset($config['tools']) || array_intersect($config['tools'], $selected_tools)) { + return; + } + + $this->processNpmManifests($config); + + if (isset($config['files'])) { + $files = array_map(fn($file): string => $this->tmpDir . '/' . $file, $config['files']); + File::remove($files); + } + + if (isset($config['ahoy'])) { + foreach ($config['ahoy'] as $string) { + File::replaceContentInFile($this->tmpDir . '/.ahoy.yml', Replacement::create('ahoy_tool', function (string $content) use ($string): string { + $content = File::replaceContent($content, $string, ''); + return Yaml::collapseEmptyLinesInLiteralBlock($content); + })); + } + } + + $this->processContent($config); + + if (isset($config['token'])) { + File::removeTokenAsync($config['token']); + } + } + + /** + * Queue the content removals declared by a tool or a group. + * + * @param array $config + * Tool or group definition. + */ + protected function processContent(array $config): void { + if (!isset($config['strings']) && !isset($config['lines'])) { + return; + } + File::replaceContentAsync( - function (string $content, ContentFile $file) use ($tool): string { - if (isset($tool['strings'])) { - foreach ($tool['strings'] as $string) { + function (string $content, ContentFile $file) use ($config): string { + if (isset($config['strings'])) { + foreach ($config['strings'] as $string) { if (Strings::isRegex($string)) { $replaced = preg_replace($string, '', $content, -1, $count); @@ -184,9 +230,9 @@ function (string $content, ContentFile $file) use ($tool): string { } } - if (isset($tool['lines'])) { + if (isset($config['lines'])) { $relative_file_path = str_replace($this->tmpDir . '/', '', $file->getPathname()); - foreach ($tool['lines'] as $relative_lines_file_name => $lines) { + foreach ($config['lines'] as $relative_lines_file_name => $lines) { if ($relative_file_path === $relative_lines_file_name) { foreach ($lines as $line) { $content = File::removeLine($content, $line); @@ -198,35 +244,42 @@ function (string $content, ContentFile $file) use ($tool): string { return $content; } ); - - File::removeTokenAsync('TOOL_' . strtoupper($name)); } - protected function processGroup(string $name): void { - $config = self::getToolDefinitions('groups')[$name]; - $selected_tools = $this->getResponseAsArray(); - - if (!isset($config['tools']) || array_intersect($config['tools'], $selected_tools)) { - return; - } - - if (isset($config['files'])) { - $files = array_map(fn($file): string => $this->tmpDir . '/' . $file, $config['files']); - File::remove($files); + /** + * Apply the npm manifest edits declared by a tool or a group. + * + * @param array $config + * Tool or group definition. + */ + protected function processNpmManifests(array $config): void { + if (isset($config['package.json']) && is_callable($config['package.json'])) { + $this->updateNpmManifest($this->tmpDir . '/package.json', $config['package.json']); } - if (isset($config['ahoy'])) { - foreach ($config['ahoy'] as $string) { - File::replaceContentInFile($this->tmpDir . '/.ahoy.yml', Replacement::create('ahoy_tool', function (string $content) use ($string): string { - $content = File::replaceContent($content, $string, ''); - return Yaml::collapseEmptyLinesInLiteralBlock($content); - })); + if (isset($config['theme.package.json']) && is_callable($config['theme.package.json'])) { + foreach ($this->themeManifests() as $manifest) { + $this->updateNpmManifest($manifest, $config['theme.package.json']); } } + } - if (isset($config['token'])) { - File::removeTokenAsync($config['token']); - } + protected function updateNpmManifest(string $manifest, callable $callback): void { + JsonManipulator::updateFile($manifest, $callback); + + // A lock file that still lists the removed dependencies makes 'npm ci' + // abort on the first build. + NpmLock::sync($manifest); + } + + /** + * Find the manifests of the custom themes. + * + * @return array + * Paths to the "package.json" files. + */ + protected function themeManifests(): array { + return glob($this->tmpDir . '/' . $this->webroot . '/themes/custom/*/package.json') ?: []; } public static function getToolDefinitions(string $filter = 'all'): array { @@ -317,6 +370,24 @@ public static function getToolDefinitions(string $filter = 'all'): array { $pj->addSubNode('scripts', 'lint', 'npm run lint-css'); $pj->addSubNode('scripts', 'lint-fix', 'npm run lint-fix-css'); }, + 'theme.package.json' => function (JsonManipulator $pj): void { + $pj->removeSubNode('devDependencies', '@eslint/compat'); + $pj->removeSubNode('devDependencies', '@eslint/js'); + $pj->removeSubNode('devDependencies', 'eslint'); + $pj->removeSubNode('devDependencies', 'eslint-config-prettier'); + $pj->removeSubNode('devDependencies', 'eslint-plugin-import'); + $pj->removeSubNode('devDependencies', 'eslint-plugin-jsdoc'); + $pj->removeSubNode('devDependencies', 'eslint-plugin-no-jquery'); + $pj->removeSubNode('devDependencies', 'eslint-plugin-prettier'); + $pj->removeSubNode('devDependencies', 'eslint-plugin-yml'); + $pj->removeSubNode('devDependencies', 'globals'); + $pj->removeSubNode('devDependencies', 'prettier'); + $pj->removeSubNode('devDependencies', '@homer0/prettier-plugin-jsdoc'); + $pj->removeSubNode('scripts', 'lint-js'); + $pj->removeSubNode('scripts', 'lint-js-fix'); + $pj->addSubNode('scripts', 'lint', 'npm run lint-css'); + $pj->addSubNode('scripts', 'lint-fix', 'npm run lint-css-fix'); + }, // A project created before the move to flat config still carries the // legacy files, which linger unread once the tool is deselected. 'files' => ['eslint.config.mjs', '.eslintrc.json', '.eslintignore', '.prettierrc.json', '.prettierignore'], @@ -335,6 +406,17 @@ public static function getToolDefinitions(string $filter = 'all'): array { $pj->addSubNode('scripts', 'lint', 'npm run lint-js'); $pj->addSubNode('scripts', 'lint-fix', 'npm run lint-fix-js'); }, + 'theme.package.json' => function (JsonManipulator $pj): void { + $pj->removeSubNode('devDependencies', 'stylelint'); + $pj->removeSubNode('devDependencies', 'stylelint-config-standard'); + $pj->removeSubNode('devDependencies', 'stylelint-config-standard-scss'); + $pj->removeSubNode('devDependencies', 'stylelint-order'); + $pj->removeSubNode('devDependencies', 'stylelint-scss'); + $pj->removeSubNode('scripts', 'lint-css'); + $pj->removeSubNode('scripts', 'lint-css-fix'); + $pj->addSubNode('scripts', 'lint', 'npm run lint-js'); + $pj->addSubNode('scripts', 'lint-fix', 'npm run lint-js-fix'); + }, 'files' => ['.stylelintrc.js'], ], @@ -500,9 +582,25 @@ public static function getToolDefinitions(string $filter = 'all'): array { ], 'frontend_linting' => [ 'tools' => [self::ESLINT, self::STYLELINT], + // Each linter rewrites 'lint' to call the other one, so with both + // deselected the pair points at scripts that no longer exist. + 'package.json' => function (JsonManipulator $pj): void { + $pj->removeSubNode('scripts', 'lint'); + $pj->removeSubNode('scripts', 'lint-fix'); + }, + 'theme.package.json' => function (JsonManipulator $pj): void { + $pj->removeSubNode('scripts', 'lint'); + $pj->removeSubNode('scripts', 'lint-fix'); + }, 'ahoy' => [ '/^\h*ahoy cli "npm run lint"\h*\n?/m', '/^\h*ahoy cli "npm run lint-fix"\h*\n?/m', + 'ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint"', + 'ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix"', + ], + 'strings' => [ + '/^\|\h*`npm run lint`.*\n?/m', + '/^\|\h*`npm run lint-fix`.*\n?/m', ], 'token' => 'TOOL_ESLINT_STYLELINT', ], diff --git a/.vortex/installer/src/Utils/NpmLock.php b/.vortex/installer/src/Utils/NpmLock.php new file mode 100644 index 0000000000..f023b3df78 --- /dev/null +++ b/.vortex/installer/src/Utils/NpmLock.php @@ -0,0 +1,209 @@ +packages) || !isset($lock->packages->{''})) { + throw new \RuntimeException(sprintf('Unsupported lock file format at "%s": a "packages" entry is required.', $lock_file)); + } + + self::reconcileRoot($manifest, $lock->packages->{''}, $manifest_file); + self::prune($lock->packages); + + self::write($lock_file, $lock); + } + + /** + * Copy the manifest's dependency blocks onto the lock's root entry. + */ + protected static function reconcileRoot(\stdClass $manifest, \stdClass $root, string $manifest_file): void { + foreach (self::BLOCKS as $block) { + if (!isset($manifest->{$block})) { + unset($root->{$block}); + + continue; + } + + if (!$manifest->{$block} instanceof \stdClass) { + throw new \RuntimeException(sprintf('Unable to read the "%s" block of "%s": a JSON object is required.', $block, $manifest_file)); + } + + $root->{$block} = clone $manifest->{$block}; + } + } + + /** + * Remove installed packages that no roots reach any more. + */ + protected static function prune(\stdClass $packages): void { + $paths = array_keys(get_object_vars($packages)); + $queue = array_values(array_filter($paths, fn(string $path): bool => !str_starts_with($path, 'node_modules/'))); + $reachable = []; + + while ($queue !== []) { + $path = array_shift($queue); + + if (isset($reachable[$path])) { + continue; + } + + $reachable[$path] = TRUE; + + foreach (self::edges($packages->{$path}) as $name) { + $resolved = self::resolve($packages, $path, $name); + + if ($resolved !== NULL) { + $queue[] = $resolved; + } + } + } + + foreach ($paths as $path) { + if (!isset($reachable[$path])) { + unset($packages->{$path}); + } + } + } + + /** + * Collect the names a lock entry depends on. + * + * @return array + * Package names. + */ + protected static function edges(\stdClass $entry): array { + $names = []; + + foreach (self::BLOCKS as $block) { + if (isset($entry->{$block})) { + $names = array_merge($names, array_keys(get_object_vars($entry->{$block}))); + } + } + + return $names; + } + + /** + * Find the entry that satisfies a name for a package at the given path. + * + * Node walks the "node_modules" directories from the requiring package + * outwards, so the innermost copy wins over a hoisted one. + * + * @return string|null + * The path of the satisfying entry, or NULL when the tree has none. + */ + protected static function resolve(\stdClass $packages, string $from, string $name): ?string { + $scope = $from; + + while (TRUE) { + $candidate = ($scope === '' ? '' : $scope . '/') . 'node_modules/' . $name; + + if (isset($packages->{$candidate})) { + return $candidate; + } + + if ($scope === '') { + return NULL; + } + + $position = strrpos($scope, '/node_modules/'); + $scope = $position === FALSE ? '' : substr($scope, 0, $position); + } + } + + /** + * Decode a JSON file into objects. + */ + protected static function read(string $file): \stdClass { + $contents = file_get_contents($file); + + if ($contents === FALSE) { + // @codeCoverageIgnoreStart + throw new \RuntimeException(sprintf('Unable to read a JSON file at "%s".', $file)); + // @codeCoverageIgnoreEnd + } + + try { + $decoded = json_decode($contents, FALSE, 512, JSON_THROW_ON_ERROR); + } + catch (\JsonException $exception) { + throw new \RuntimeException(sprintf('Unable to parse a JSON file at "%s": %s', $file, $exception->getMessage()), $exception->getCode(), $exception); + } + + if (!$decoded instanceof \stdClass) { + throw new \RuntimeException(sprintf('Unable to parse a JSON file at "%s": a JSON object is required.', $file)); + } + + return $decoded; + } + + /** + * Write a JSON file the way npm writes it. + */ + protected static function write(string $file, \stdClass $data): void { + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + // Two-space indentation matches what npm writes; PHP indents with four. A + // JSON string cannot hold a raw newline, so a leading run of spaces is + // always indentation. + $json = preg_replace_callback('/^ +/m', fn(array $matches): string => str_repeat(' ', intdiv(strlen($matches[0]), 2)), $json); + + if ($json === NULL) { + // @codeCoverageIgnoreStart + throw new \RuntimeException(sprintf('Unable to format a JSON file at "%s".', $file)); + // @codeCoverageIgnoreEnd + } + + $json .= "\n"; + + if (!is_writable($file) || file_put_contents($file, $json) !== strlen($json)) { + throw new \RuntimeException(sprintf('Unable to write a JSON file at "%s".', $file)); + } + } + +} diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.ahoy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.ahoy.yml index 1471846975..b569385e32 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.ahoy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.ahoy.yml @@ -1,20 +1,22 @@ -@@ -207,7 +207,6 @@ +@@ -207,8 +207,6 @@ usage: Lint front-end code. cmd: | ahoy cli vendor/bin/twig-cs-fixer lint - ahoy cli "npm run lint" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" lint-tests: -@@ -239,7 +238,6 @@ + usage: Lint tests code. +@@ -239,8 +237,6 @@ usage: Fix lint issues of front-end code. cmd: | ahoy cli vendor/bin/twig-cs-fixer lint --fix - ahoy cli "npm run lint-fix" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" test: -@@ -262,10 +260,6 @@ + usage: Run all PHPUnit tests. +@@ -262,10 +258,6 @@ test-functional-javascript: usage: Run PHPUnit functional JavaScript tests. cmd: ahoy cli vendor/bin/phpunit --testsuite=functional-javascript "$@" diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.github/workflows/build-test-deploy.yml index 634f265837..452179b6d7 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.github/workflows/build-test-deploy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/.github/workflows/build-test-deploy.yml @@ -6,7 +6,7 @@ - name: Validate Composer configuration is normalized run: docker compose exec -T cli composer normalize --dry-run -@@ -158,10 +157,6 @@ +@@ -158,15 +157,6 @@ run: docker compose exec -T cli vendor/bin/gherkinlint lint tests/behat/features continue-on-error: ${{ vars.VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE == '1' }} @@ -14,10 +14,15 @@ - run: docker compose exec -T cli bash -c "npm run lint" - continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} - - - name: Lint theme code with NodeJS linters - if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} - run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" -@@ -310,7 +305,6 @@ +- - name: Lint theme code with NodeJS linters +- if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} +- run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" +- continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} +- + database: + runs-on: ubuntu-latest + if: ${{ !inputs.deploy_target && (github.event_name == 'push' || !startsWith(github.head_ref, 'project/')) }} +@@ -310,7 +300,6 @@ # https://www.vortextemplate.com/docs/continuous-integration#test-parallelism VORTEX_CI_RUNNER_INDEX: ${{ strategy.job-index }} VORTEX_CI_RUNNER_TOTAL: ${{ strategy.job-total }} @@ -25,7 +30,7 @@ VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }} VORTEX_CI_IS_SDC_DEVEL_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }} # Runs on every instance, using the `p` profile. -@@ -421,7 +415,6 @@ +@@ -421,7 +410,6 @@ docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli bash -c " \ if [ -n \"\${PACKAGE_TOKEN:-}\" ]; then composer config --global --auth github-oauth.github.com \"\${PACKAGE_TOKEN}\"; fi && \ COMPOSER_MEMORY_LIMIT=-1 composer --ansi install --prefer-dist" @@ -33,7 +38,7 @@ - name: Provision site run: | -@@ -434,11 +427,6 @@ +@@ -434,11 +422,6 @@ fi docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli ./vendor/bin/vortex-provision timeout-minutes: 30 diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/README.md b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/README.md new file mode 100644 index 0000000000..3d20bae61b --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/README.md @@ -0,0 +1,6 @@ +@@ -20,5 +20,3 @@ + | `npm run build` | Production build (minified, no source maps) | + | `npm run build-dev` | Development build (expanded, with source maps) | + | `npm run watch` | Watch for changes and rebuild automatically | +-| `npm run lint` | Check code style (JS and SCSS) | +-| `npm run lint-fix` | Fix code style issues automatically | diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..1fc10ca089 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint/web/themes/custom/star_wars/package.json @@ -0,0 +1,43 @@ +@@ -4,29 +4,12 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,12 +25,6 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.ahoy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.ahoy.yml index 1471846975..b569385e32 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.ahoy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.ahoy.yml @@ -1,20 +1,22 @@ -@@ -207,7 +207,6 @@ +@@ -207,8 +207,6 @@ usage: Lint front-end code. cmd: | ahoy cli vendor/bin/twig-cs-fixer lint - ahoy cli "npm run lint" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" lint-tests: -@@ -239,7 +238,6 @@ + usage: Lint tests code. +@@ -239,8 +237,6 @@ usage: Fix lint issues of front-end code. cmd: | ahoy cli vendor/bin/twig-cs-fixer lint --fix - ahoy cli "npm run lint-fix" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" test: -@@ -262,10 +260,6 @@ + usage: Run all PHPUnit tests. +@@ -262,10 +258,6 @@ test-functional-javascript: usage: Run PHPUnit functional JavaScript tests. cmd: ahoy cli vendor/bin/phpunit --testsuite=functional-javascript "$@" diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.circleci/config.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.circleci/config.yml index 29f3b1ee0d..79680d0552 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.circleci/config.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/.circleci/config.yml @@ -166,12 +166,6 @@ jobs: name: Lint code with Gherkin Lint command: docker compose exec -T cli vendor/bin/gherkinlint lint tests/behat/features || [ "${VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE:-0}" -eq 1 ] - - run: - name: Lint theme code with NodeJS linters - command: | - [ "${VORTEX_FRONTEND_BUILD_SKIP:-0}" -eq 1 ] && exit 0 - docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" || [ "${VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE:-0}" -eq 1 ] - # Audit job runs in its own workflow, independently of the commit workflow. audit: <<: *runner_config diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/README.md b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/README.md new file mode 100644 index 0000000000..3d20bae61b --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/README.md @@ -0,0 +1,6 @@ +@@ -20,5 +20,3 @@ + | `npm run build` | Production build (minified, no source maps) | + | `npm run build-dev` | Development build (expanded, with source maps) | + | `npm run watch` | Watch for changes and rebuild automatically | +-| `npm run lint` | Check code style (JS and SCSS) | +-| `npm run lint-fix` | Fix code style issues automatically | diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..1fc10ca089 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_groups_no_fe_lint_circleci/web/themes/custom/star_wars/package.json @@ -0,0 +1,43 @@ +@@ -4,29 +4,12 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,12 +25,6 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/.github/workflows/build-test-deploy.yml index 5ff6756de7..9ff95c0bff 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/.github/workflows/build-test-deploy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/.github/workflows/build-test-deploy.yml @@ -6,14 +6,19 @@ - name: Validate Composer configuration is normalized run: docker compose exec -T cli composer normalize --dry-run -@@ -157,10 +156,6 @@ +@@ -157,15 +156,6 @@ - name: Lint code with Gherkin Lint run: docker compose exec -T cli vendor/bin/gherkinlint lint tests/behat/features continue-on-error: ${{ vars.VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE == '1' }} - - - name: Lint module code with NodeJS linters - run: docker compose exec -T cli bash -c "npm run lint" +- continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} +- +- - name: Lint theme code with NodeJS linters +- if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} +- run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" - continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} - - name: Lint theme code with NodeJS linters - if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} + database: + runs-on: ubuntu-latest diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..89c2302583 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint/web/themes/custom/star_wars/package.json @@ -0,0 +1,39 @@ +@@ -4,23 +4,11 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", + "stylelint": "__VERSION__", + "stylelint-config-standard": "__VERSION__", +@@ -42,12 +30,10 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", ++ "lint": "npm run lint-css", + "lint-css": "stylelint 'scss/**/*.scss'", + "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", ++ "lint-fix": "npm run lint-css-fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/.circleci/config.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/.circleci/config.yml index 79947bcdf8..0567b2d914 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/.circleci/config.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/.circleci/config.yml @@ -166,12 +166,6 @@ jobs: name: Lint code with Gherkin Lint command: docker compose exec -T cli vendor/bin/gherkinlint lint tests/behat/features || [ "${VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE:-0}" -eq 1 ] - - run: - name: Lint theme code with NodeJS linters - command: | - [ "${VORTEX_FRONTEND_BUILD_SKIP:-0}" -eq 1 ] && exit 0 - docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" || [ "${VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE:-0}" -eq 1 ] - # Audit job runs in its own workflow, independently of the commit workflow. audit: <<: *runner_config diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..89c2302583 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_circleci/web/themes/custom/star_wars/package.json @@ -0,0 +1,39 @@ +@@ -4,23 +4,11 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", + "stylelint": "__VERSION__", + "stylelint-config-standard": "__VERSION__", +@@ -42,12 +30,10 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", ++ "lint": "npm run lint-css", + "lint-css": "stylelint 'scss/**/*.scss'", + "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", ++ "lint-fix": "npm run lint-css-fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.prettierignore b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.prettierignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.prettierrc.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.prettierrc.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.stylelintrc.js b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-.stylelintrc.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-eslint.config.mjs b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/-eslint.config.mjs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.ahoy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.ahoy.yml new file mode 100644 index 0000000000..f2af61a6c3 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.ahoy.yml @@ -0,0 +1,18 @@ +@@ -207,8 +207,6 @@ + usage: Lint front-end code. + cmd: | + ahoy cli vendor/bin/twig-cs-fixer lint +- ahoy cli "npm run lint" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" + + lint-tests: + usage: Lint tests code. +@@ -239,8 +237,6 @@ + usage: Fix lint issues of front-end code. + cmd: | + ahoy cli vendor/bin/twig-cs-fixer lint --fix +- ahoy cli "npm run lint-fix" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" + + test: + usage: Run all PHPUnit tests. diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.github/workflows/build-test-deploy.yml new file mode 100644 index 0000000000..9ff95c0bff --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/.github/workflows/build-test-deploy.yml @@ -0,0 +1,24 @@ +@@ -132,7 +132,6 @@ + docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli bash -c " \ + if [ -n \"\${PACKAGE_TOKEN:-}\" ]; then composer config --global --auth github-oauth.github.com \"\${PACKAGE_TOKEN}\"; fi && \ + COMPOSER_MEMORY_LIMIT=-1 composer --ansi install --prefer-dist" +- docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli bash -c "npm ci" + + - name: Validate Composer configuration is normalized + run: docker compose exec -T cli composer normalize --dry-run +@@ -157,15 +156,6 @@ + - name: Lint code with Gherkin Lint + run: docker compose exec -T cli vendor/bin/gherkinlint lint tests/behat/features + continue-on-error: ${{ vars.VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE == '1' }} +- +- - name: Lint module code with NodeJS linters +- run: docker compose exec -T cli bash -c "npm run lint" +- continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} +- +- - name: Lint theme code with NodeJS linters +- if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} +- run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" +- continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} + + database: + runs-on: ubuntu-latest diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/package.json new file mode 100644 index 0000000000..f7afc86c64 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/package.json @@ -0,0 +1,34 @@ +@@ -8,32 +8,11 @@ + "node": ">= __VERSION__" + }, + "scripts": { +- "lint-js": "eslint web/modules/custom --max-warnings=0 --no-error-on-unmatched-pattern", +- "lint-css": "stylelint --allow-empty-input \"web/modules/custom/**/*.css\"", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-fix-js": "eslint web/modules/custom --no-error-on-unmatched-pattern --fix", +- "lint-fix-css": "stylelint --allow-empty-input \"web/modules/custom/**/*.css\" --fix", +- "lint-fix": "npm run lint-fix-js && npm run lint-fix-css", + "test": "jest --coverage --passWithNoTests" + }, + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "jest": "__VERSION__", +- "jest-environment-jsdom": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", +- "prettier": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-order": "__VERSION__" ++ "jest-environment-jsdom": "__VERSION__" + }, + "overrides": { + "test-exclude": "__VERSION__", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/README.md b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/README.md new file mode 100644 index 0000000000..3d20bae61b --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/README.md @@ -0,0 +1,6 @@ +@@ -20,5 +20,3 @@ + | `npm run build` | Production build (minified, no source maps) | + | `npm run build-dev` | Development build (expanded, with source maps) | + | `npm run watch` | Watch for changes and rebuild automatically | +-| `npm run lint` | Check code style (JS and SCSS) | +-| `npm run lint-fix` | Fix code style issues automatically | diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..1fc10ca089 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_eslint_no_stylelint/web/themes/custom/star_wars/package.json @@ -0,0 +1,43 @@ +@@ -4,29 +4,12 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,12 +25,6 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..04445b1033 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint/web/themes/custom/star_wars/package.json @@ -0,0 +1,25 @@ +@@ -22,11 +22,6 @@ + "postcss-cli": "__VERSION__", + "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,10 +37,8 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", ++ "lint": "npm run lint-js", ++ "lint-fix": "npm run lint-js-fix", + "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", + "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint_circleci/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint_circleci/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..04445b1033 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_no_stylelint_circleci/web/themes/custom/star_wars/package.json @@ -0,0 +1,25 @@ +@@ -22,11 +22,6 @@ + "postcss-cli": "__VERSION__", + "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,10 +37,8 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", ++ "lint": "npm run lint-js", ++ "lint-fix": "npm run lint-js-fix", + "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", + "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_none/.ahoy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_none/.ahoy.yml index 7bc2ffa568..43f2735563 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_none/.ahoy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_none/.ahoy.yml @@ -1,4 +1,4 @@ -@@ -192,29 +192,13 @@ +@@ -192,29 +192,11 @@ lint: usage: Lint back-end and front-end code. cmd: | @@ -18,8 +18,8 @@ cmd: | - ahoy cli vendor/bin/twig-cs-fixer lint - ahoy cli "npm run lint" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" - +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint" +- - lint-tests: - usage: Lint tests code. - cmd: | @@ -28,7 +28,7 @@ lint-sdc: usage: Validate Single Directory Components (requires a provisioned site). cmd: | -@@ -226,51 +210,12 @@ +@@ -226,52 +208,11 @@ lint-fix: usage: Fix lint issues of back-end and front-end code. cmd: | @@ -46,7 +46,7 @@ cmd: | - ahoy cli vendor/bin/twig-cs-fixer lint --fix - ahoy cli "npm run lint-fix" - ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" +- ahoy cli "npm run --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} lint-fix" - - test: - usage: Run all PHPUnit tests. @@ -77,6 +77,7 @@ - usage: Run BDD tests. - aliases: [test-behat] - cmd: ahoy cli php -d memory_limit=-1 vendor/bin/behat --colors "$@" - +- debug: usage: Enable PHP Xdebug. + aliases: [xdebug] diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_none/.github/workflows/build-test-deploy.yml b/.vortex/installer/tests/Fixtures/handler_process/tools_none/.github/workflows/build-test-deploy.yml index 0e19289829..4ab2193e11 100644 --- a/.vortex/installer/tests/Fixtures/handler_process/tools_none/.github/workflows/build-test-deploy.yml +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_none/.github/workflows/build-test-deploy.yml @@ -16,7 +16,7 @@ - name: Build stack run: docker compose up --no-deps --detach cli -@@ -132,36 +121,11 @@ +@@ -132,41 +121,11 @@ docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli bash -c " \ if [ -n \"\${PACKAGE_TOKEN:-}\" ]; then composer config --global --auth github-oauth.github.com \"\${PACKAGE_TOKEN}\"; fi && \ COMPOSER_MEMORY_LIMIT=-1 composer --ansi install --prefer-dist" @@ -50,10 +50,15 @@ - run: docker compose exec -T cli bash -c "npm run lint" - continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} - - - name: Lint theme code with NodeJS linters - if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} - run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" -@@ -310,15 +274,7 @@ +- - name: Lint theme code with NodeJS linters +- if: ${{ vars.VORTEX_FRONTEND_BUILD_SKIP != '1' }} +- run: docker compose exec -T cli bash -c "npm --prefix=\${WEBROOT}/themes/custom/\${DRUPAL_THEME} run lint" +- continue-on-error: ${{ vars.VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE == '1' }} +- + database: + runs-on: ubuntu-latest + if: ${{ !inputs.deploy_target && (github.event_name == 'push' || !startsWith(github.head_ref, 'project/')) }} +@@ -310,15 +269,7 @@ # https://www.vortextemplate.com/docs/continuous-integration#test-parallelism VORTEX_CI_RUNNER_INDEX: ${{ strategy.job-index }} VORTEX_CI_RUNNER_TOTAL: ${{ strategy.job-total }} @@ -69,7 +74,7 @@ container: # https://hub.docker.com/r/drevops/ci-runner -@@ -421,7 +377,6 @@ +@@ -421,7 +372,6 @@ docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli bash -c " \ if [ -n \"\${PACKAGE_TOKEN:-}\" ]; then composer config --global --auth github-oauth.github.com \"\${PACKAGE_TOKEN}\"; fi && \ COMPOSER_MEMORY_LIMIT=-1 composer --ansi install --prefer-dist" @@ -77,7 +82,7 @@ - name: Provision site run: | -@@ -435,71 +390,6 @@ +@@ -435,71 +385,6 @@ docker compose exec $(env | cut -f1 -d= | sed 's/^/-e /') -T cli ./vendor/bin/vortex-provision timeout-minutes: 30 @@ -149,7 +154,7 @@ - name: Validate Single Directory Components if: ${{ env.VORTEX_CI_IS_SDC_DEVEL_RUNNER == 'true' }} run: | -@@ -512,19 +402,6 @@ +@@ -512,19 +397,6 @@ fi continue-on-error: ${{ vars.VORTEX_CI_SDC_DEVEL_IGNORE_FAILURE == '1' }} @@ -169,7 +174,7 @@ - name: Process test logs and artifacts if: always() run: | -@@ -541,16 +418,6 @@ +@@ -541,16 +413,6 @@ path: .logs include-hidden-files: true if-no-files-found: error diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/README.md b/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/README.md new file mode 100644 index 0000000000..3d20bae61b --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/README.md @@ -0,0 +1,6 @@ +@@ -20,5 +20,3 @@ + | `npm run build` | Production build (minified, no source maps) | + | `npm run build-dev` | Development build (expanded, with source maps) | + | `npm run watch` | Watch for changes and rebuild automatically | +-| `npm run lint` | Check code style (JS and SCSS) | +-| `npm run lint-fix` | Fix code style issues automatically | diff --git a/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/package.json b/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/package.json new file mode 100644 index 0000000000..1fc10ca089 --- /dev/null +++ b/.vortex/installer/tests/Fixtures/handler_process/tools_none/web/themes/custom/star_wars/package.json @@ -0,0 +1,43 @@ +@@ -4,29 +4,12 @@ + "private": true, + "description": "NodeJS dependencies for star wars project", + "devDependencies": { +- "@eslint/compat": "__VERSION__", +- "@eslint/js": "__VERSION__", +- "@homer0/prettier-plugin-jsdoc": "__VERSION__", + "autoprefixer": "__VERSION__", + "chokidar-cli": "__VERSION__", +- "eslint": "__VERSION__", +- "eslint-config-prettier": "__VERSION__", +- "eslint-plugin-import": "__VERSION__", +- "eslint-plugin-jsdoc": "__VERSION__", +- "eslint-plugin-no-jquery": "__VERSION__", +- "eslint-plugin-prettier": "__VERSION__", +- "eslint-plugin-yml": "__VERSION__", +- "globals": "__VERSION__", + "patch-package": "__VERSION__", + "postcss": "__VERSION__", + "postcss-cli": "__VERSION__", +- "prettier": "__VERSION__", + "sass": "__VERSION__", +- "stylelint": "__VERSION__", +- "stylelint-config-standard": "__VERSION__", +- "stylelint-config-standard-scss": "__VERSION__", +- "stylelint-order": "__VERSION__", +- "stylelint-scss": "__VERSION__", + "terser": "__VERSION__" + }, + "browserslist": [ +@@ -42,12 +25,6 @@ + "copy": "npm run copy:images && npm run copy:fonts", + "copy:fonts": "mkdir -p build/fonts && cp -r fonts/* build/fonts/ 2>/dev/null || true", + "copy:images": "mkdir -p build/images && cp -r images/* build/images/ 2>/dev/null || true", +- "lint": "npm run lint-js && npm run lint-css", +- "lint-css": "stylelint 'scss/**/*.scss'", +- "lint-css-fix": "stylelint 'scss/**/*.scss' --fix", +- "lint-fix": "npm run lint-js-fix && npm run lint-css-fix", +- "lint-js": "eslint 'js/**/*.js' --ignore-pattern '*.min.js'", +- "lint-js-fix": "eslint 'js/**/*.js' --ignore-pattern '*.min.js' --fix", + "postcss:dev": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --map", + "postcss:prod": "postcss build/css/star_wars.min.css -o build/css/star_wars.min.css --no-map", + "postinstall": "patch-package", diff --git a/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php index 1309d523a1..08b80401cf 100644 --- a/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php +++ b/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php @@ -264,6 +264,18 @@ public static function dataProviderHandlerProcess(): \Iterator { $test->assertFileDoesNotExist(static::$sut . '/.prettierignore'); $test->assertFileContainsString($pj, '"stylelint":'); $test->assertFileExists(static::$sut . '/.stylelintrc.js'); + + $tpj = static::themeManifest(); + $test->assertFileNotContainsString($tpj, '"eslint":'); + $test->assertFileNotContainsString($tpj, '"prettier":'); + $test->assertFileContainsString($tpj, '"stylelint":'); + + static::assertNpmPairIsInSync($pj); + static::assertNpmPairIsInSync($tpj); + static::assertNpmLockLacksPackages($pj, ['eslint', 'prettier']); + static::assertNpmLockLacksPackages($tpj, ['eslint', 'prettier']); + static::assertNpmLockHasPackages($pj, ['stylelint', 'jest']); + static::assertNpmLockHasPackages($tpj, ['stylelint', 'sass']); }), ]; yield 'tools_no_eslint_circleci' => [ @@ -305,6 +317,55 @@ public static function dataProviderHandlerProcess(): \Iterator { $test->assertFileDoesNotExist(static::$sut . '/.stylelintrc.js'); $test->assertFileContainsString($pj, '"eslint":'); $test->assertFileExists(static::$sut . '/eslint.config.mjs'); + + $tpj = static::themeManifest(); + $test->assertFileNotContainsString($tpj, '"stylelint":'); + $test->assertFileNotContainsString($tpj, '"stylelint-scss":'); + $test->assertFileContainsString($tpj, '"eslint":'); + + static::assertNpmPairIsInSync($pj); + static::assertNpmPairIsInSync($tpj); + static::assertNpmLockLacksPackages($pj, ['stylelint']); + static::assertNpmLockLacksPackages($tpj, ['stylelint', 'stylelint-scss']); + static::assertNpmLockHasPackages($pj, ['eslint', 'jest']); + static::assertNpmLockHasPackages($tpj, ['eslint', 'sass']); + }), + ]; + yield 'tools_no_eslint_no_stylelint' => [ + static::cw(function ($test): void { + $tools = array_keys(Tools::getToolDefinitions('tools')); + $test->prompts[Tools::id()] = array_values(array_diff($tools, [Tools::ESLINT, Tools::STYLELINT])); + $test->prompts[CiProvider::id()] = CiProvider::GITHUB_ACTIONS; + }), + static::cw(function (AbstractHandlerProcessTestCase $test): void { + $pj = static::$sut . '/package.json'; + $tpj = static::themeManifest(); + + $test->assertFileNotContainsString($pj, '"eslint":'); + $test->assertFileNotContainsString($pj, '"stylelint":'); + $test->assertFileNotContainsString($pj, '"lint":'); + $test->assertFileNotContainsString($pj, '"lint-fix":'); + $test->assertFileContainsString($pj, '"jest":'); + + $test->assertFileNotContainsString($tpj, '"eslint":'); + $test->assertFileNotContainsString($tpj, '"stylelint":'); + $test->assertFileNotContainsString($tpj, '"lint":'); + $test->assertFileNotContainsString($tpj, '"lint-fix":'); + $test->assertFileContainsString($tpj, '"sass":'); + + $test->assertFileDoesNotExist(static::$sut . '/eslint.config.mjs'); + $test->assertFileDoesNotExist(static::$sut . '/.stylelintrc.js'); + $test->assertFileExists(static::$sut . '/jest.config.js'); + + static::assertNpmPairIsInSync($pj); + static::assertNpmPairIsInSync($tpj); + static::assertNpmLockLacksPackages($pj, ['eslint', 'stylelint']); + static::assertNpmLockLacksPackages($tpj, ['eslint', 'stylelint']); + static::assertNpmLockHasPackages($pj, ['jest']); + static::assertNpmLockHasPackages($tpj, ['sass']); + + $test->assertSutContains(['npm ci']); + $test->assertSutNotContains(['npm run lint']); }), ]; yield 'tools_no_stylelint_circleci' => [ @@ -433,6 +494,10 @@ public static function dataProviderHandlerProcess(): \Iterator { $test->assertFileDoesNotExist(static::$sut . '/jest.config.js'); $test->assertFileContainsString($pj, '"eslint":'); $test->assertFileContainsString($pj, '"stylelint":'); + + static::assertNpmPairIsInSync($pj); + static::assertNpmLockLacksPackages($pj, ['jest', 'jest-environment-jsdom']); + static::assertNpmLockHasPackages($pj, ['eslint', 'stylelint']); }), ]; yield 'tools_no_jest_circleci' => [ @@ -610,4 +675,51 @@ public static function dataProviderHandlerProcess(): \Iterator { ]; } + protected static function themeManifest(): string { + $manifests = glob(static::$sut . '/*/themes/custom/*/package.json') ?: []; + self::assertCount(1, $manifests, 'Expected a single custom theme manifest.'); + + return $manifests[0]; + } + + /** + * Assert that a manifest and its lock file declare the same dependencies. + * + * This is the condition 'npm ci' refuses to install without. + */ + protected static function assertNpmPairIsInSync(string $manifest_file): void { + $manifest = static::readJson($manifest_file); + $root = static::readJson(dirname($manifest_file) . '/package-lock.json')['packages']['']; + + foreach (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as $block) { + self::assertSame( + $manifest[$block] ?? NULL, + $root[$block] ?? NULL, + sprintf('The "%s" block of "%s" does not match its lock file.', $block, $manifest_file) + ); + } + } + + protected static function assertNpmLockHasPackages(string $manifest_file, array $names): void { + $packages = static::readJson(dirname($manifest_file) . '/package-lock.json')['packages']; + + foreach ($names as $name) { + self::assertArrayHasKey('node_modules/' . $name, $packages, sprintf('Package "%s" is missing from the lock file next to "%s".', $name, $manifest_file)); + } + } + + protected static function assertNpmLockLacksPackages(string $manifest_file, array $names): void { + $packages = static::readJson(dirname($manifest_file) . '/package-lock.json')['packages']; + + foreach ($names as $name) { + self::assertArrayNotHasKey('node_modules/' . $name, $packages, sprintf('Package "%s" is still in the lock file next to "%s".', $name, $manifest_file)); + } + } + + protected static function readJson(string $file): array { + self::assertFileExists($file); + + return (array) json_decode((string) file_get_contents($file), TRUE, 512, JSON_THROW_ON_ERROR); + } + } diff --git a/.vortex/installer/tests/Unit/Utils/NpmLockTest.php b/.vortex/installer/tests/Unit/Utils/NpmLockTest.php new file mode 100644 index 0000000000..ecbdd5ac0e --- /dev/null +++ b/.vortex/installer/tests/Unit/Utils/NpmLockTest.php @@ -0,0 +1,250 @@ +createPair(['devDependencies' => ['keep' => '^1.0.0']], NULL); + + NpmLock::sync($manifest_file); + + $this->assertFileDoesNotExist(dirname($manifest_file) . '/package-lock.json'); + } + + #[DataProvider('dataProviderSync')] + public function testSync(array $manifest, array $lock, array $expected_root, array $expected_paths): void { + $manifest_file = $this->createPair($manifest, $lock); + + NpmLock::sync($manifest_file); + + $actual = $this->readLock($manifest_file); + + $this->assertSame($expected_root, $actual['packages']['']); + $this->assertSame($expected_paths, array_keys($actual['packages'])); + } + + public static function dataProviderSync(): \Iterator { + yield 'dependency removed from the manifest is pruned with its orphans' => [ + ['devDependencies' => ['keep' => '^1.0.0']], + [ + 'packages' => [ + '' => ['name' => 'test', 'devDependencies' => ['keep' => '^1.0.0', 'drop' => '^2.0.0']], + 'node_modules/deep-orphan' => ['version' => '1.0.0'], + 'node_modules/drop' => ['version' => '2.0.0', 'dependencies' => ['orphan' => '^1.0.0', 'shared' => '^1.0.0']], + 'node_modules/drop/node_modules/nested' => ['version' => '1.0.0'], + 'node_modules/keep' => ['version' => '1.0.0', 'dependencies' => ['shared' => '^1.0.0']], + 'node_modules/orphan' => ['version' => '1.0.0', 'dependencies' => ['deep-orphan' => '^1.0.0']], + 'node_modules/shared' => ['version' => '1.0.0'], + ], + ], + ['name' => 'test', 'devDependencies' => ['keep' => '^1.0.0']], + ['', 'node_modules/keep', 'node_modules/shared'], + ]; + yield 'a nested copy shadows the hoisted one' => [ + ['dependencies' => ['keep' => '^1.0.0']], + [ + 'packages' => [ + '' => ['dependencies' => ['keep' => '^1.0.0']], + 'node_modules/keep' => ['version' => '1.0.0', 'dependencies' => ['shared' => '^2.0.0']], + 'node_modules/keep/node_modules/shared' => ['version' => '2.0.0'], + 'node_modules/shared' => ['version' => '1.0.0'], + ], + ], + ['dependencies' => ['keep' => '^1.0.0']], + ['', 'node_modules/keep', 'node_modules/keep/node_modules/shared'], + ]; + yield 'a dependency of a nested copy falls back to the hoisted tree' => [ + ['dependencies' => ['keep' => '^1.0.0']], + [ + 'packages' => [ + '' => ['dependencies' => ['keep' => '^1.0.0']], + 'node_modules/keep' => ['version' => '1.0.0', 'dependencies' => ['nested' => '^1.0.0']], + 'node_modules/keep/node_modules/nested' => ['version' => '1.0.0', 'dependencies' => ['hoisted' => '^1.0.0']], + 'node_modules/hoisted' => ['version' => '1.0.0'], + ], + ], + ['dependencies' => ['keep' => '^1.0.0']], + ['', 'node_modules/keep', 'node_modules/keep/node_modules/nested', 'node_modules/hoisted'], + ]; + yield 'every dependency block is followed' => [ + [ + 'dependencies' => ['runtime' => '^1.0.0'], + 'devDependencies' => ['dev' => '^1.0.0'], + 'optionalDependencies' => ['optional' => '^1.0.0'], + 'peerDependencies' => ['peer' => '^1.0.0'], + ], + [ + 'packages' => [ + '' => ['name' => 'test'], + 'node_modules/dev' => ['version' => '1.0.0'], + 'node_modules/optional' => ['version' => '1.0.0'], + 'node_modules/peer' => ['version' => '1.0.0'], + 'node_modules/runtime' => ['version' => '1.0.0'], + 'node_modules/unreferenced' => ['version' => '1.0.0'], + ], + ], + [ + 'name' => 'test', + 'dependencies' => ['runtime' => '^1.0.0'], + 'devDependencies' => ['dev' => '^1.0.0'], + 'optionalDependencies' => ['optional' => '^1.0.0'], + 'peerDependencies' => ['peer' => '^1.0.0'], + ], + ['', 'node_modules/dev', 'node_modules/optional', 'node_modules/peer', 'node_modules/runtime'], + ]; + yield 'a block absent from the manifest is dropped from the lock' => [ + ['dependencies' => ['runtime' => '^1.0.0']], + [ + 'packages' => [ + '' => ['dependencies' => ['runtime' => '^1.0.0'], 'devDependencies' => ['dev' => '^1.0.0']], + 'node_modules/dev' => ['version' => '1.0.0'], + 'node_modules/runtime' => ['version' => '1.0.0'], + ], + ], + ['dependencies' => ['runtime' => '^1.0.0']], + ['', 'node_modules/runtime'], + ]; + yield 'a manifest entry the lock does not carry is added' => [ + ['dependencies' => ['added' => '^1.0.0', 'changed' => '^2.0.0']], + [ + 'packages' => [ + '' => ['dependencies' => ['changed' => '^1.0.0']], + 'node_modules/changed' => ['version' => '1.0.0'], + ], + ], + ['dependencies' => ['added' => '^1.0.0', 'changed' => '^2.0.0']], + ['', 'node_modules/changed'], + ]; + yield 'a workspace is a root of its own' => [ + ['dependencies' => ['app' => '*']], + [ + 'packages' => [ + '' => ['dependencies' => ['app' => '*']], + 'node_modules/app' => ['resolved' => 'packages/app', 'link' => TRUE], + 'node_modules/workspace-only' => ['version' => '1.0.0'], + 'node_modules/unreferenced' => ['version' => '1.0.0'], + 'packages/app' => ['version' => '1.0.0', 'dependencies' => ['workspace-only' => '^1.0.0']], + ], + ], + ['dependencies' => ['app' => '*']], + ['', 'node_modules/app', 'node_modules/workspace-only', 'packages/app'], + ]; + yield 'an unresolvable optional dependency is skipped' => [ + ['dependencies' => ['keep' => '^1.0.0']], + [ + 'packages' => [ + '' => ['dependencies' => ['keep' => '^1.0.0']], + 'node_modules/keep' => ['version' => '1.0.0', 'optionalDependencies' => ['never-installed' => '^1.0.0']], + ], + ], + ['dependencies' => ['keep' => '^1.0.0']], + ['', 'node_modules/keep'], + ]; + } + + public function testSyncWritesTheFileTheWayNpmWritesIt(): void { + $manifest_file = $this->createPair( + ['dependencies' => ['keep' => '^1.0.0']], + [ + 'name' => 'test', + 'lockfileVersion' => 3, + 'packages' => [ + '' => ['dependencies' => ['keep' => '^1.0.0']], + 'node_modules/keep' => ['version' => '1.0.0', 'resolved' => 'https://registry.npmjs.org/keep/-/keep-1.0.0.tgz'], + ], + ] + ); + + NpmLock::sync($manifest_file); + + $contents = (string) file_get_contents(dirname($manifest_file) . '/package-lock.json'); + + $this->assertStringContainsString("\n \"lockfileVersion\": 3,", $contents); + $this->assertStringContainsString("\n \"node_modules/keep\": {", $contents); + $this->assertStringContainsString('https://registry.npmjs.org/keep/-/keep-1.0.0.tgz', $contents); + $this->assertStringEndsWith("}\n", $contents); + } + + public function testSyncPreservesEmptyObjects(): void { + $manifest_file = $this->createPair(['dependencies' => ['keep' => '^1.0.0']], NULL); + + File::dump(dirname($manifest_file) . '/package-lock.json', '{"packages":{"":{"dependencies":{"keep":"^1.0.0"}},"node_modules/keep":{"version":"1.0.0","bin":{}}}}'); + + NpmLock::sync($manifest_file); + + $this->assertStringContainsString('"bin": {}', (string) file_get_contents(dirname($manifest_file) . '/package-lock.json')); + } + + #[DataProvider('dataProviderSyncThrows')] + public function testSyncThrows(?string $manifest_contents, ?string $lock_contents, string $message): void { + $dir = File::mkdir(self::$tmp . '/' . uniqid('npmlock_')); + + File::dump($dir . '/package.json', $manifest_contents ?? '{}'); + File::dump($dir . '/package-lock.json', $lock_contents ?? '{}'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches($message); + + NpmLock::sync($dir . '/package.json'); + } + + public static function dataProviderSyncThrows(): \Iterator { + yield 'lock without a packages key' => [NULL, '{"lockfileVersion":1,"dependencies":{}}', '/Unsupported lock file format/']; + yield 'lock without a root entry' => [NULL, '{"packages":{"node_modules/keep":{}}}', '/Unsupported lock file format/']; + yield 'manifest with invalid JSON' => ['{"name":}', NULL, '/Unable to parse a JSON file/']; + yield 'lock with invalid JSON' => [NULL, '{"packages":}', '/Unable to parse a JSON file/']; + yield 'manifest that is not an object' => ['[]', NULL, '/a JSON object is required/']; + yield 'manifest with a dependency block that is not an object' => ['{"devDependencies":[]}', '{"packages":{"":{}}}', '/Unable to read the "devDependencies" block/']; + } + + public function testSyncThrowsWhenLockIsNotWritable(): void { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('File permissions do not restrict the root user.'); + } + + $manifest_file = $this->createPair( + ['dependencies' => ['keep' => '^1.0.0']], + ['packages' => ['' => ['dependencies' => ['keep' => '^1.0.0']]]] + ); + + chmod(dirname($manifest_file) . '/package-lock.json', 0444); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Unable to write a JSON file/'); + + NpmLock::sync($manifest_file); + } + + /** + * Write a manifest and its lock file into a directory of their own. + * + * @return string + * Path to the manifest. + */ + protected function createPair(array $manifest, ?array $lock): string { + $dir = File::mkdir(self::$tmp . '/' . uniqid('npmlock_')); + + File::dump($dir . '/package.json', (string) json_encode($manifest, JSON_PRETTY_PRINT)); + + if ($lock !== NULL) { + File::dump($dir . '/package-lock.json', (string) json_encode($lock, JSON_PRETTY_PRINT)); + } + + return $dir . '/package.json'; + } + + protected function readLock(string $manifest_file): array { + return (array) json_decode((string) file_get_contents(dirname($manifest_file) . '/package-lock.json'), TRUE, 512, JSON_THROW_ON_ERROR); + } + +}