diff --git a/.gitignore b/.gitignore index a24a7ee..76d83e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,11 @@ -# CRAFT ENVIRONMENT -.env.php -.env.sh -.env - -# COMPOSER -/vendor +.idea +.php_cs +.php_cs.cache +.php-cs-fixer.cache +.phpunit.result.cache +/build composer.lock +vendor +*.log +/tests/_craft/storage/ -# BUILD FILES -/bower_components/* -/node_modules/* -/build/* -/yarn-error.log - -# MISC FILES -.cache -.DS_Store -.idea -.project -.settings -*.esproj -*.sublime-workspace -*.sublime-project -*.tmproj -*.tmproject -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -config.codekit3 -prepros-6.config diff --git a/CHANGELOG.md b/CHANGELOG.md index 407c653..efb08a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [2.0.0] - Unreleased + + + ## [1.9.2] - 2022-03-26 - Fix issues with Supertable / exclude SuperTableBlockElement diff --git a/composer.json b/composer.json index 913f2a1..f2314ad 100644 --- a/composer.json +++ b/composer.json @@ -24,20 +24,26 @@ } ], "require": { - "craftcms/cms": "^3.2.0", + "craftcms/cms": "^4.1.0", "guzzlehttp/guzzle": "^6.5.5|^7.2.0" }, "require-dev": { - "vimeo/psalm": "^4.4" + "craftcms/phpstan": "*", + "friendsofphp/php-cs-fixer": "^3.0", + "pestphp/pest": "^1.2", + "pestphp/pest-plugin-parallel": "^1.0" }, "autoload": { "psr-4": { - "ostark\\upper\\": "src/" - } + "ostark\\Upper\\": "src/" + }, + "files": [ + "src/helpers.php" + ] }, "scripts": { - "ps": "phpstan analyse src --level=5 -c phpstan.neon", - "stan": "@ps" + "phpstan": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest" }, "extra": { "name": "Upper", @@ -45,5 +51,16 @@ "hasCpSettings": false, "hasCpSection": false, "changelogUrl": "https://raw.githubusercontent.com/ostark/upper/master/CHANGELOG.md" - } + }, + "config": { + "allow-plugins": { + "yiisoft/yii2-composer": true, + "composer/package-versions-deprecated": true, + "craftcms/plugin-installer": true, + "pestphp/pest-plugin": true + } + }, + + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..be0a203 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - vendor/craftcms/phpstan/phpstan.neon +parameters: + level: 6 + paths: + - src + - tests + tmpDir: build/phpstan + checkMissingIterableValueType: false + ignoreErrors: + - "#Unable to resolve the template type T in call to method static method#" diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9203aad --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + tests + + + + + ./src + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 5509c48..0000000 --- a/psalm.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/CacheResponse.php b/src/CacheResponse.php index 71d3309..f6df701 100644 --- a/src/CacheResponse.php +++ b/src/CacheResponse.php @@ -1,11 +1,11 @@ -getRequest(); - - // Don't cache CP, LivePreview, Action, Non-GET requests - if ($request->getIsCpRequest() || - $request->getIsLivePreview() || - $request->getIsActionRequest() || - !$request->getIsGet() - ) { - $response = \Craft::$app->getResponse(); - $response->addCacheControlDirective('private'); - $response->addCacheControlDirective('no-cache'); - - return false; - } - - // Collect tags - Event::on(ElementQuery::class, ElementQuery::EVENT_AFTER_POPULATE_ELEMENT, function (PopulateElementEvent $event) { - - // Don't collect MatrixBlock and User elements for now - if (!Plugin::getInstance()->getSettings()->isCachableElement(get_class($event->element))) { - return; - } - - // Tag with GlobalSet handle - if ($event->element instanceof \craft\elements\GlobalSet) { - Plugin::getInstance()->getTagCollection()->add($event->element->handle); - } - - // Add to collection - Plugin::getInstance()->getTagCollection()->addTagsFromElement($event->row); - - }); - - // Add the tags to the response header - Event::on(View::class, View::EVENT_AFTER_RENDER_PAGE_TEMPLATE, function (TemplateEvent $event) { - - /** @var \yii\web\Response $response */ - $response = \Craft::$app->getResponse(); - $plugin = Plugin::getInstance(); - $tagCollection = $plugin->getTagCollection(); - $tags = $plugin->getTagCollection()->getAll(); - $settings = $plugin->getSettings(); - $headers = $response->getHeaders(); - - // Make existing cache-control headers accessible - $response->setCacheControlDirectiveFromString($headers->get('cache-control')); - - // Don't cache if private | no-cache set already - if ($response->hasCacheControlDirective('private') || $response->hasCacheControlDirective('no-cache')) { - $headers->set(Plugin::INFO_HEADER_NAME, 'BYPASS'); - - return; - } - - // MaxAge or defaultMaxAge? - $maxAge = $response->getMaxAge() ?? $settings->defaultMaxAge; - - // Set Headers - $maxBytes = $settings->maxBytesForCacheTagHeader; - $maxedTags = $tagCollection->getUntilMaxBytes($maxBytes); - $response->setTagHeader($settings->getTagHeaderName(), $maxedTags, $settings->getHeaderTagDelimiter()); - - // Flag truncation - if (count($tags) > count($maxedTags)) { - $headers->set(Plugin::TRUNCATED_HEADER_NAME, count($tags) - count($maxedTags)); - } - - $response->setSharedMaxAge($maxAge); - $headers->set(Plugin::INFO_HEADER_NAME, "CACHED: " . date(\DateTime::ISO8601)); - - $plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent([ - 'tags' => $tags, - 'maxAge' => $maxAge, - 'requestUrl' => \Craft::$app->getRequest()->getUrl(), - 'headers' => $response->getHeaders()->toArray() - ] - )); - }); - - } - - - public static function registerCpEvents() - { - // Register cache purge checkbox - Event::on( - ClearCaches::class, - ClearCaches::EVENT_REGISTER_CACHE_OPTIONS, - function (RegisterCacheOptionsEvent $event) { - $driver = ucfirst(Plugin::getInstance()->getSettings()->driver); - $event->options[] = [ - 'key' => 'upper-purge-all', - 'label' => \Craft::t('upper', 'Upper ({driver})', ['driver' => $driver]), - 'action' => function () { - Plugin::getInstance()->getPurger()->purgeAll(); - }, - ]; - } - ); - } - - - public static function registerFallback() - { - - Event::on(Plugin::class, Plugin::EVENT_AFTER_SET_TAG_HEADER, function (CacheResponseEvent $event) { - - // not tagged? - if (0 == count($event->tags)) { - return; - } - - // fulltext or array - $tags = \Craft::$app->getDb()->getIsMysql() - ? implode(" ", $event->tags) - : str_replace(['[', ']'], ['{', '}'], json_encode($event->tags) ?: '[]'); - - // in order to have a unique (collitions are possible) identifier by url with a fixed length - $urlHash = md5($event->requestUrl); - - try { - // Insert item - \Craft::$app->getDb()->createCommand() - ->upsert( - // Table - Plugin::CACHE_TABLE, - - // Identifier - ['urlHash' => $urlHash], - - // Data - [ - 'urlHash' => $urlHash, - 'url' => $event->requestUrl, - 'tags' => $tags, - 'headers' => json_encode($event->headers), - 'siteId' => \Craft::$app->getSites()->currentSite->id - ] - ) - ->execute(); - } catch (\Exception $e) { - \Craft::warning("Failed to register fallback.", "upper"); - } - - }); - - } - - - /** - * @param \yii\base\Event $event - */ - protected static function handleUpdateEvent(Event $event) - { - $tags = []; - - - if ($event instanceof ElementEvent) { - - if (!Plugin::getInstance()->getSettings()->isCachableElement(get_class($event->element))) { - return; - } - - // Prevent purge on updates of drafts or revisions - if (ElementHelper::isDraftOrRevision($event->element)) { - return; - } - - // Prevent purge on resaving - if (property_exists($event->element, 'resaving') && $event->element->resaving === true) { - return; - } - - if ($event->element instanceof \craft\elements\GlobalSet && is_string($event->element->handle)) { - $tags[] = $event->element->handle; - } elseif ($event->element instanceof \craft\elements\Asset && $event->isNew) { - $tags[] = (string)$event->element->volumeId; - } else { - if (isset($event->element->sectionId)) { - $tags[] = Plugin::TAG_PREFIX_SECTION . $event->element->sectionId; - } - if (!$event->isNew) { - $tags[] = Plugin::TAG_PREFIX_ELEMENT . $event->element->getId(); - } - } - } - - if ($event instanceof SectionEvent) { - $tags[] = Plugin::TAG_PREFIX_SECTION . $event->section->id; - } - - if ($event instanceof MoveElementEvent or $event instanceof ElementStructureEvent) { - $tags[] = Plugin::TAG_PREFIX_STRUCTURE . $event->structureId; - } - - if (count($tags) === 0) { - return; - } - - foreach ($tags as $tag) { - $tag = Plugin::getInstance()->getTagCollection()->prepareTag($tag); - - $purgeEvent = new PurgeEvent([ - 'tag' => $tag, - ]); - - Plugin::getInstance()->trigger(Plugin::EVENT_BEFORE_PURGE, $purgeEvent); - - // Push to queue - \Craft::$app->getQueue()->push(new PurgeCacheJob([ - 'tag' => $purgeEvent->tag - ] - )); - - Plugin::getInstance()->trigger(Plugin::EVENT_AFTER_PURGE, $purgeEvent); - } - - } - -} diff --git a/src/events/CacheResponseEvent.php b/src/Events/CacheResponseEvent.php similarity index 86% rename from src/events/CacheResponseEvent.php rename to src/Events/CacheResponseEvent.php index 4c56501..d2a2b7c 100644 --- a/src/events/CacheResponseEvent.php +++ b/src/Events/CacheResponseEvent.php @@ -1,11 +1,11 @@ -settings = $settings; + $this->tags = $tags; + } + + public function __invoke(TemplateEvent $event): void + { + /** @var \yii\web\Response $response */ + $response = \Craft::$app->getResponse(); + $plugin = Plugin::getInstance(); + $tagCollection = $this->tags; + $tags = $this->tags->getAll(); + $headers = $response->getHeaders(); + + // Make existing cache-control headers accessible + $response->setCacheControlDirectiveFromString($headers->get('cache-control')); + + // Don't cache if private | no-cache set already + if ($response->hasCacheControlDirective('private') || $response->hasCacheControlDirective('no-cache')) { + $headers->set(Plugin::INFO_HEADER_NAME, 'BYPASS'); + + return; + } + + // MaxAge or defaultMaxAge? + $maxAge = $response->getMaxAge() ?? $this->settings->defaultMaxAge; + + // Set Headers + $maxBytes = $this->settings->maxBytesForCacheTagHeader; + $maxedTags = $tagCollection->getUntilMaxBytes($maxBytes); + $response->setTagHeader($this->settings->getTagHeaderName(), $maxedTags, $this->settings->getHeaderTagDelimiter()); + + // Flag truncation + if (count($tags) > count($maxedTags)) { + $headers->set(Plugin::TRUNCATED_HEADER_NAME, count($tags) - count($maxedTags)); + } + + $response->setSharedMaxAge($maxAge); + $headers->set(Plugin::INFO_HEADER_NAME, "CACHED: " . date(\DateTime::ISO8601)); + + $plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent([ + 'tags' => $tags, + 'maxAge' => $maxAge, + 'requestUrl' => \Craft::$app->getRequest()->getUrl(), + 'headers' => $response->getHeaders()->toArray() + ] + )); + + } +} diff --git a/src/Handlers/CollectTagsFromElementQuery.php b/src/Handlers/CollectTagsFromElementQuery.php new file mode 100644 index 0000000..4d5574d --- /dev/null +++ b/src/Handlers/CollectTagsFromElementQuery.php @@ -0,0 +1,34 @@ +settings = $settings; + $this->tags = $tags; + } + + public function __invoke(PopulateElementEvent $event): void + { + // Don't collect MatrixBlock and User elements for now + if (!$this->settings->isCachableElement(get_class($event->element))) { + return; + } + + // Tag with GlobalSet handle + if ($event->element instanceof \craft\elements\GlobalSet) { + $this->tags->add($event->element->handle); + } + + // Add to collection + $this->tags->addTagsFromElement($event->row); + } +} diff --git a/src/Handlers/CollectTagsFromTemplateCache.php b/src/Handlers/CollectTagsFromTemplateCache.php new file mode 100644 index 0000000..d0232dc --- /dev/null +++ b/src/Handlers/CollectTagsFromTemplateCache.php @@ -0,0 +1,17 @@ +settings = $settings; + } + + public function __invoke(): void + { + } +} diff --git a/src/Handlers/InvalidateCache.php b/src/Handlers/InvalidateCache.php new file mode 100644 index 0000000..44ffc34 --- /dev/null +++ b/src/Handlers/InvalidateCache.php @@ -0,0 +1,89 @@ +settings = $settings; + } + + public function __invoke(Event $event): void + { + + $tags = []; + + if ($event instanceof ElementEvent) { + + if (!$this->settings->isCachableElement(get_class($event->element))) { + return; + } + + // Prevent purge on updates of drafts or revisions + if (ElementHelper::isDraftOrRevision($event->element)) { + return; + } + + // Prevent purge on resaving + if (property_exists($event->element, 'resaving') && $event->element->resaving === true) { + return; + } + + if ($event->element instanceof \craft\elements\GlobalSet && is_string($event->element->handle)) { + $tags[] = $event->element->handle; + } elseif ($event->element instanceof \craft\elements\Asset && $event->isNew) { + $tags[] = (string)$event->element->volumeId; + } else { + if (isset($event->element->sectionId)) { + $tags[] = Plugin::TAG_PREFIX_SECTION . $event->element->sectionId; + } + if (!$event->isNew) { + $tags[] = Plugin::TAG_PREFIX_ELEMENT . $event->element->getId(); + } + } + } + + if ($event instanceof SectionEvent) { + $tags[] = Plugin::TAG_PREFIX_SECTION . $event->section->id; + } + + if ($event instanceof MoveElementEvent or $event instanceof ElementStructureEvent) { + $tags[] = Plugin::TAG_PREFIX_STRUCTURE . $event->structureId; + } + + if (count($tags) === 0) { + return; + } + + foreach ($tags as $tag) { + $tag = Plugin::getInstance()->getTagCollection()->prepareTag($tag); + + $purgeEvent = new PurgeEvent([ + 'tag' => $tag, + ]); + + Plugin::getInstance()->trigger(Plugin::EVENT_BEFORE_PURGE, $purgeEvent); + + // Push to queue + \Craft::$app->getQueue()->push(new PurgeCacheJob([ + 'tag' => $purgeEvent->tag + ] + )); + + Plugin::getInstance()->trigger(Plugin::EVENT_AFTER_PURGE, $purgeEvent); + } + + } +} diff --git a/src/Handlers/RegisterCacheCheckbox.php b/src/Handlers/RegisterCacheCheckbox.php new file mode 100644 index 0000000..a236f16 --- /dev/null +++ b/src/Handlers/RegisterCacheCheckbox.php @@ -0,0 +1,32 @@ +settings = $settings; + $this->purger = $purger; + } + + public function __invoke(RegisterCacheOptionsEvent $event, CachePurgeInterface $purger): void + { + $driver = ucfirst($this->settings->driver); + $event->options[] = [ + 'key' => 'upper-purge-all', + 'label' => \Craft::t('upper', 'Upper ({driver})', ['driver' => $driver]), + 'action' => function () { + $this->purger->purgeAll(); + }, + ]; + + + } +} diff --git a/src/Handlers/StoreTagUrlRelation.php b/src/Handlers/StoreTagUrlRelation.php new file mode 100644 index 0000000..0714007 --- /dev/null +++ b/src/Handlers/StoreTagUrlRelation.php @@ -0,0 +1,55 @@ +tags)) { + return; + } + + // fulltext or array + $tags = \Craft::$app->getDb()->getIsMysql() + ? implode(" ", $event->tags) + : str_replace(['[', ']'], ['{', '}'], json_encode($event->tags) ?: '[]'); + + // in order to have a unique (collitions are possible) identifier by url with a fixed length + $urlHash = md5($event->requestUrl); + + try { + // Insert item + \Craft::$app->getDb()->createCommand() + ->upsert( + // Table + Plugin::CACHE_TABLE, + + // Identifier + ['urlHash' => $urlHash], + + // Data + [ + 'urlHash' => $urlHash, + 'url' => $event->requestUrl, + 'tags' => $tags, + 'headers' => json_encode($event->headers), + 'siteId' => \Craft::$app->getSites()->currentSite->id + ] + ) + ->execute(); + } catch (\Exception $e) { + \Craft::warning("Failed to register fallback.", "upper"); + } + + } +} diff --git a/src/jobs/PurgeCacheJob.php b/src/Jobs/PurgeCacheJob.php similarity index 87% rename from src/jobs/PurgeCacheJob.php rename to src/Jobs/PurgeCacheJob.php index 7210177..87a4aa3 100644 --- a/src/jobs/PurgeCacheJob.php +++ b/src/Jobs/PurgeCacheJob.php @@ -1,13 +1,13 @@ -setComponents([ - 'purger' => PurgerFactory::create($this->getSettings()->toArray()), - 'tagCollection' => TagCollection::class - ]); + // Register TagCollection in container + Craft::$container->setSingleton(TagCollection::class, function () { + $collection = new TagCollection(); + $collection->setKeyPrefix($this->getSettings()->getKeyPrefix()); + return $collection; + }); + + // Register Purger in container + Craft::$container->set(CachePurgeInterface::class , function () { + return PurgerFactory::create($this->getSettings()->toArray()); + }); // Attach Behaviors - \Craft::$app->getResponse()->attachBehavior('cache-control', CacheControlBehavior::class); - \Craft::$app->getResponse()->attachBehavior('tag-header', TagHeaderBehavior::class); + // TODO -> different implementation + // \Craft::$app->getResponse()->attachBehavior('cache-control', CacheControlBehavior::class); + // \Craft::$app->getResponse()->attachBehavior('tag-header', TagHeaderBehavior::class); // Register event handlers - EventRegistrar::registerFrontendEvents(); - EventRegistrar::registerCpEvents(); - EventRegistrar::registerUpdateEvents(); - - if ($this->getSettings()->useLocalTags) { - EventRegistrar::registerFallback(); - } + $this->registerFrontendEventHandlers(); + $this->registerCpEventHandlers(); + $this->registerUpdateEventHandlers(); // Register Twig extension \Craft::$app->getView()->registerTwigExtension(new TwigExtension); } - // ServiceLocators - // ========================================================================= - /** - * @return \ostark\upper\drivers\CachePurgeInterface - */ - public function getPurger(): CachePurgeInterface + private function registerFrontendEventHandlers(): void { - return $this->get('purger'); + if ($this->isNotCacheable()) { + // TODO + // $response = \Craft::$app->getResponse(); + // $response->addCacheControlDirective('private'); + //$response->addCacheControlDirective('no-cache'); + return; + } + + Event::on( + ElementQuery::class, + ElementQuery::EVENT_AFTER_POPULATE_ELEMENT, + new CollectTagsFromElementQuery($this->getSettings(), tags()) + ); + + Event::on( + ElementQuery::class, + ElementQuery::EVENT_DEFINE_CACHE_TAGS, + new CollectTagsFromTemplateCache($this->getSettings()) + ); + + Event::on( + View::class, + View::EVENT_AFTER_RENDER_PAGE_TEMPLATE, + new AddCacheResponse($this->getSettings(), tags()) + ); + + Event::on( + Plugin::class, + Plugin::EVENT_AFTER_SET_TAG_HEADER, + new StoreTagUrlRelation() + ); + } + private function registerCpEventHandlers(): void + { + Event::on( + ClearCaches::class, + ClearCaches::EVENT_REGISTER_CACHE_OPTIONS, + new RegisterCacheCheckbox($this->getSettings(), purger()) + ); + } - /** - * @return \ostark\upper\TagCollection - */ - public function getTagCollection(): TagCollection + private function registerUpdateEventHandlers(): void { - /* @var \ostark\upper\TagCollection $collection */ - $collection = $this->get('tagCollection'); - $collection->setKeyPrefix($this->getSettings()->getKeyPrefix()); + $handler = new InvalidateCache($this->getSettings()); - return $collection; + Event::on(Elements::class, Elements::EVENT_AFTER_SAVE_ELEMENT, $handler); + Event::on(Element::class, Element::EVENT_AFTER_MOVE_IN_STRUCTURE, $handler); + Event::on(Elements::class, Elements::EVENT_AFTER_DELETE_ELEMENT, $handler); + Event::on(Structures::class, Structures::EVENT_AFTER_MOVE_ELEMENT, $handler); + Event::on(Sections::class, Sections::EVENT_AFTER_SAVE_SECTION, $handler); } - // Protected Methods - // ========================================================================= + private function isNotCacheable(): bool + { + if (\Craft::$app instanceof \craft\console\Application) { + return true; + } + + $request = \Craft::$app->getRequest(); + + if ($request->getIsCpRequest() || + $request->getIsLivePreview() || + $request->getIsActionRequest() || + !$request->getIsGet() + ) { + return true; + } + return false; + } + /** * Creates and returns the model used to store the plugin’s settings. - * - * @return \craft\base\Model|null */ - protected function createSettingsModel() + protected function createSettingsModel(): PluginSettings { - return new Settings(); + return new PluginSettings(); } @@ -122,7 +186,7 @@ protected function createSettingsModel() * Is called after the plugin is installed. * Copies example config to project's config folder */ - protected function afterInstall() + protected function afterInstall():void { $configSourceFile = __DIR__ . DIRECTORY_SEPARATOR . 'config.example.php'; $configTargetFile = \Craft::$app->getConfig()->configDir . DIRECTORY_SEPARATOR . $this->handle . '.php'; @@ -131,5 +195,4 @@ protected function afterInstall() copy($configSourceFile, $configTargetFile); } } - } diff --git a/src/PurgerFactory.php b/src/PurgerFactory.php index 6f9dfb2..0456838 100644 --- a/src/PurgerFactory.php +++ b/src/PurgerFactory.php @@ -1,16 +1,16 @@ -set('foo', new \my\FooDummy()); +}); + +it('is always true', function () { + + $someResult = true; + + // Assert + expect($someResult)->toBeTrue(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..6d4256c --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,39 @@ +in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bfe6e85..9ed0f43 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,13 +1,56 @@ env = $environment; +$configService->configDir = CRAFT_CONFIG_PATH; +$configService->appDefaultsDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'defaults'; +$generalConfig = $configService->getConfigFromFile('general'); + +$config = \craft\helpers\ArrayHelper::merge( + [ + 'vendorPath' => CRAFT_VENDOR_PATH, + 'env' => $environment, + 'components' => [ + 'config' => $configService, + ], + 'id' => 'test', + 'basePath' => __DIR__, + 'class' => craft\console\Application::class, + ], + require 'vendor/craftcms/cms/src/config/app.php', + require 'vendor/craftcms/cms/src/config/app.console.php', + $configService->getConfigFromFile('app'), +); + +// Initialize the application +/** @var \craft\web\Application|craft\console\Application $app */ +$app = Craft::createObject($config); + + +// Load and run Craft +/** @var craft\console\Application $app */ +\Craft::$app = $app;