2 Commits
v0.1.5 ... dev

Author SHA1 Message Date
5e4af247fa Make PHP version configurable in Dockerfile
Add BuildKit syntax directive and PHP_VERSION build arg (default 8.4)
to allow building with different PHP versions without editing the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:55:52 +01:00
4031a1c8aa Add PHPUnit test suite with Brain\Monkey (v0.1.6)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m2s
Create Release Package / PHPUnit Tests (push) Successful in 46s
Create Release Package / Build Release (push) Successful in 50s
Add test infrastructure for isolated unit testing without WordPress/WooCommerce:
- 27 tests (54 assertions) covering TemplateOverride and WooCommerceExtension
- Brain\Monkey for WordPress function mocking, class stubs for TwigService and WC_Product
- PHPUnit test job added to Gitea CI pipeline between lint and build-release
- Test artifacts excluded from release packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:48:19 +01:00
15 changed files with 2682 additions and 11 deletions

View File

@@ -24,10 +24,31 @@ jobs:
run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
test:
name: PHPUnit Tests
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, dom
tools: composer:v2
- name: Install Composer dependencies
run: composer install --no-interaction
- name: Run PHPUnit
run: composer exec -- phpunit
build-release:
name: Build Release
runs-on: ubuntu-latest
needs: [lint]
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -88,8 +109,8 @@ jobs:
cp -a . "${STAGING_DIR}/${THEME_NAME}"
cd "${STAGING_DIR}/${THEME_NAME}"
rm -rf .git .gitea .github .vscode .claude releases node_modules
rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig
rm -rf .git .gitea .github .vscode .claude releases node_modules tests .phpunit.cache
rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig phpunit.xml.dist
find . -name '*.log' -o -name '*.po~' -o -name '*.bak' -o -name '.DS_Store' | xargs rm -f 2>/dev/null || true
cd "${STAGING_DIR}"

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ releases/
# Docker runtime
.env
KNOWN_BUGS.md
.phpunit.cache/

View File

@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## [0.1.6] - 2026-03-01
### Added
- **PHPUnit test suite** with Brain\Monkey for WordPress function mocking (27 tests, 54 assertions)
- **TemplateOverrideTest**: Tests for hook registration, Twig template resolution, output buffer stack (including nesting), context passing, global `$product` injection, and exception fallback
- **WooCommerceExtensionTest**: Tests for function/filter registration, `callFunction()` whitelist enforcement, `doAction`/`applyFilters`/`callUserFunc` output capture, `setupProductData`, and all output-capture wrappers
- **CI/CD test job**: PHPUnit runs between lint and build-release in Gitea pipeline; test artifacts excluded from release packages
- Class stubs for `WPBootstrap\Twig\TwigService` and `WC_Product` enabling isolated unit testing without WordPress/WooCommerce
## [0.1.5] - 2026-03-01
### Fixed

View File

@@ -345,10 +345,39 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr
## Version History
Current version: **v0.1.5**
Current version: **v0.1.6**
## Session History
### 2026-03-01 — v0.1.6 Add PHPUnit Test Suite
**Scope:** Added PHPUnit test infrastructure with Brain\Monkey for WordPress function mocking, covering both PHP classes (`TemplateOverride`, `WooCommerceExtension`). Added test job to Gitea CI pipeline.
**Files created (7):**
- `phpunit.xml.dist` — PHPUnit 11 configuration (bootstrap, test suite, source coverage)
- `tests/bootstrap.php` — Loads Composer autoloader and class stubs
- `tests/Stubs/TwigService.php``WPBootstrap\Twig\TwigService` stub with injectable render callback and singleton reset
- `tests/Stubs/WcProduct.php` — Minimal `\WC_Product` stub with `get_id()`
- `tests/Unit/TemplateOverrideTest.php` — 9 tests: hook registration, Twig resolution, buffer stack (including nesting), context passing, global `$product` injection, exception fallback
- `tests/Unit/Twig/WooCommerceExtensionTest.php` — 18 tests: function/filter registration, `callFunction()` whitelist enforcement, output capture wrappers, `setupProductData`
**Files modified (4):**
- `composer.json` — Added `require-dev` (phpunit ^11.0, brain/monkey ^2.6) and `autoload-dev` PSR-4 mapping
- `.gitea/workflows/release.yml` — Added `test` job between `lint` and `build-release`; excluded `tests/`, `phpunit.xml.dist`, `.phpunit.cache` from release packages
- `.gitignore` — Changed `.phpunit.cache/test-results` to `.phpunit.cache/`
- `style.css` — Version bump 0.1.5 → 0.1.6
**Key decisions:**
- **Brain\Monkey over WP_Mock:** Lighter weight, better patchwork integration, same pattern as wp-bootstrap parent theme
- **TwigService stub with injectable callback:** Allows tests to control render output without full Twig environment; includes `reset()` for test isolation
- **`@` error suppression for `error_log` in fallback test:** PHP internal functions can't be mocked by Brain\Monkey without patchwork.json config; suppressing is simpler than adding patchwork config for a single test
- **No patchwork.json:** Avoids complexity; only `error_log` needed mocking and the `@` operator suffices for the test assertion
**Test results:** 27 tests, 54 assertions, all passing
### 2026-03-01 — v0.1.5 Fix 10 Known Bugs
**Scope:** Fixed all 10 bugs from KNOWN_BUGS.md — catalog page features (title, breadcrumbs, categories, filters, sort dropdown), single product fixes (variable form, gallery, related products, grouped products), and downloads page.

View File

@@ -28,8 +28,9 @@ The bridge hooks into WooCommerce's `woocommerce_before_template_part` and `wooc
- **99 Twig template overrides** covering all customer-facing WooCommerce pages
- **Rendering bridge** (`WooCommerceExtension` + `TemplateOverride`) that intercepts WooCommerce's PHP template pipeline
- **~50 Twig functions** and **7 Twig filters** exposing WooCommerce and WordPress APIs to templates
- **~50 Twig functions** and **8 Twig filters** exposing WooCommerce and WordPress APIs to templates
- Bootstrap 5 responsive markup with dark mode support
- **PHPUnit test suite** with Brain\Monkey for isolated unit testing (no WordPress/WooCommerce required)
- HPOS compatible (uses `WC_Order` methods only, no `$post` global)
- 8 reusable Twig components (card, pagination, price, rating, address-card, status-badge, quantity-input, form-field)
- Translation-ready
@@ -83,10 +84,15 @@ wc-bootstrap/
│ ├── notices/
│ ├── order/
│ └── single-product/
├── tests/
│ ├── bootstrap.php # PHPUnit bootstrap (autoloader + stubs)
│ ├── Stubs/ # WordPress/WooCommerce class stubs
│ └── Unit/ # Unit tests (TemplateOverride, WooCommerceExtension)
├── docker/
│ ├── Dockerfile # Multistage build (WC download, npm, composer, WP)
│ ├── entrypoint.sh # Auto-setup wrapper entrypoint
│ └── setup.sh # First-run WP install + plugin/theme activation
├── phpunit.xml.dist # PHPUnit 11 configuration
├── .env-dist # Environment variable template
├── compose.yaml # WordPress + MariaDB services
├── compose.override.yaml # Dev overrides (bind mounts, debug flags)
@@ -143,8 +149,8 @@ for po in languages/wc-bootstrap-*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
Releases are automated via Gitea Actions. Push a tag matching `vX.X.X` to trigger a release build.
```bash
git tag -a v0.1.5 -m "Version 0.1.5 - Fix 10 known bugs"
git push origin v0.1.5
git tag -a v0.1.6 -m "Version 0.1.6 - Add PHPUnit test suite"
git push origin v0.1.6
```
## License

View File

@@ -14,11 +14,20 @@
"php": ">=8.3",
"twig/twig": "^3.0"
},
"require-dev": {
"brain/monkey": "^2.6",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"WcBootstrap\\": "inc/"
}
},
"autoload-dev": {
"psr-4": {
"WcBootstrap\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true

2038
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
#syntax=docker/dockerfile:1
ARG PHP_VERSION=8.4
###############################################################################
# Stage 1 — Download WooCommerce from WordPress.org
###############################################################################
@@ -48,7 +51,7 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
###############################################################################
# Stage 4 — Final WordPress image
###############################################################################
FROM wordpress:php8.4 AS wp_runtime
FROM wordpress:php${PHP_VERSION} AS wp_runtime
RUN curl -sSfL -o /usr/local/bin/wp \
https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>inc</directory>
</include>
</source>
</phpunit>

View File

@@ -7,7 +7,7 @@ Description: A Bootstrap 5 child theme for WP Bootstrap that overrides all WooCo
Requires at least: 6.7
Tested up to: 6.7
Requires PHP: 8.3
Version: 0.1.5
Version: 0.1.6
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Template: wp-bootstrap

View File

@@ -0,0 +1,53 @@
<?php
/**
* Stub for WPBootstrap\Twig\TwigService.
*
* Provides just enough surface for TemplateOverride to resolve
* and render templates during unit tests.
*/
namespace WPBootstrap\Twig;
class TwigService
{
private static ?self $instance = null;
/** @var callable|null Render callback set by tests. */
private static $renderCallback = null;
public static function getInstance(): self
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Allow tests to override the render behaviour.
*/
public static function setRenderCallback(?callable $callback): void
{
self::$renderCallback = $callback;
}
/**
* Render a template with the given context.
*/
public function render(string $template, array $context = []): string
{
if (null !== self::$renderCallback) {
return (self::$renderCallback)($template, $context);
}
return '';
}
/**
* Reset singleton between tests.
*/
public static function reset(): void
{
self::$instance = null;
self::$renderCallback = null;
}
}

21
tests/Stubs/WcProduct.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
/**
* Stub for WC_Product.
*
* Minimal implementation for type-hint satisfaction in unit tests.
*/
class WC_Product
{
private int $id;
public function __construct(int $id = 0)
{
$this->id = $id;
}
public function get_id(): int
{
return $this->id;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace WcBootstrap\Tests\Unit;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WcBootstrap\TemplateOverride;
use WPBootstrap\Twig\TwigService;
class TemplateOverrideTest extends TestCase
{
private TemplateOverride $override;
private string $templatePath;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
TwigService::reset();
// WC_BOOTSTRAP_PATH must point to the theme root so
// resolveTwigTemplate() can locate files under templates/.
$this->templatePath = dirname(__DIR__, 2) . '/';
if (!defined('WC_BOOTSTRAP_PATH')) {
define('WC_BOOTSTRAP_PATH', $this->templatePath);
}
$this->override = new TemplateOverride();
}
protected function tearDown(): void
{
TwigService::reset();
Monkey\tearDown();
parent::tearDown();
}
// ── register() ──────────────────────────────────────────────
public function testRegisterAddsHooksWhenTwigServiceExists(): void
{
$calls = [];
Functions\when('add_action')->alias(function () use (&$calls): void {
$calls[] = func_get_args();
});
$this->override->register();
$this->assertCount(2, $calls);
$this->assertSame('woocommerce_before_template_part', $calls[0][0]);
$this->assertSame('woocommerce_after_template_part', $calls[1][0]);
}
// ── beforeTemplatePart / afterTemplatePart ───────────────────
public function testBeforeTemplatePartRendersAndBuffersWhenTwigTemplateExists(): void
{
// Use a real template file that exists in the theme.
// cart/cart.php -> cart/cart.html.twig
$templateName = 'cart/cart.php';
TwigService::setRenderCallback(function (string $tpl, array $ctx): string {
return '<div>twig-rendered</div>';
});
$this->override->beforeTemplatePart($templateName, '', '', []);
// Output buffer should be active — the PHP template output is being captured.
$level = ob_get_level();
$this->assertGreaterThan(0, $level);
// Now simulate the PHP template echoing something.
echo 'php-output-should-be-discarded';
$this->override->afterTemplatePart($templateName, '', '', []);
// The Twig output was already echoed before the buffer, so we
// just verify the buffer was cleaned (level decreased).
$this->assertSame($level - 1, ob_get_level());
}
public function testBeforeTemplatePartSkipsWhenNoTwigTemplate(): void
{
$levelBefore = ob_get_level();
// Use a template name that has no Twig override.
$this->override->beforeTemplatePart('nonexistent/template.php', '', '', []);
// No buffer should have been started.
$this->assertSame($levelBefore, ob_get_level());
}
public function testAfterTemplatePartIgnoresUnbufferedTemplate(): void
{
$levelBefore = ob_get_level();
// Calling after without a matching before should be safe.
$this->override->afterTemplatePart('cart/cart.php', '', '', []);
$this->assertSame($levelBefore, ob_get_level());
}
public function testNestedTemplatesHandledCorrectly(): void
{
// Both templates must exist as Twig files.
$outer = 'cart/cart.php';
$inner = 'cart/cart-empty.php';
TwigService::setRenderCallback(fn() => '<div>rendered</div>');
$levelBefore = ob_get_level();
$this->override->beforeTemplatePart($outer, '', '', []);
$outerLevel = ob_get_level();
$this->override->beforeTemplatePart($inner, '', '', []);
$innerLevel = ob_get_level();
$this->assertSame($outerLevel + 1, $innerLevel);
// Close inner first (stack order).
$this->override->afterTemplatePart($inner, '', '', []);
$this->assertSame($outerLevel, ob_get_level());
$this->override->afterTemplatePart($outer, '', '', []);
$this->assertSame($levelBefore, ob_get_level());
}
public function testBeforeTemplatePartPassesArgsAndProductToTwig(): void
{
$templateName = 'cart/cart.php';
$captured = null;
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
$captured = $ctx;
return '';
});
$args = ['foo' => 'bar'];
$this->override->beforeTemplatePart($templateName, '', '', $args);
// Clean up the buffer.
$this->override->afterTemplatePart($templateName, '', '', $args);
$this->assertIsArray($captured);
$this->assertSame('bar', $captured['foo']);
}
public function testBeforeTemplatePartInjectsGlobalProduct(): void
{
$templateName = 'cart/cart.php';
$captured = null;
$product = new \WC_Product(42);
$GLOBALS['product'] = $product;
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
$captured = $ctx;
return '';
});
$this->override->beforeTemplatePart($templateName, '', '', []);
$this->override->afterTemplatePart($templateName, '', '', []);
$this->assertSame($product, $captured['product']);
unset($GLOBALS['product']);
}
public function testBeforeTemplatePartFallsBackOnRenderException(): void
{
$templateName = 'cart/cart.php';
TwigService::setRenderCallback(function (): string {
throw new \RuntimeException('Twig error');
});
$levelBefore = ob_get_level();
// Should not throw — falls back silently and lets PHP render.
@$this->override->beforeTemplatePart($templateName, '', '', []);
// No buffer started on failure.
$this->assertSame($levelBefore, ob_get_level());
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace WcBootstrap\Tests\Unit\Twig;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use Twig\TwigFunction;
use Twig\TwigFilter;
use WcBootstrap\Twig\WooCommerceExtension;
class WooCommerceExtensionTest extends TestCase
{
private WooCommerceExtension $extension;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
$this->extension = new WooCommerceExtension();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── getFunctions / getFilters ───────────────────────────────
public function testGetFunctionsReturnsNonEmptyArray(): void
{
$functions = $this->extension->getFunctions();
$this->assertNotEmpty($functions);
$this->assertContainsOnlyInstancesOf(TwigFunction::class, $functions);
}
public function testGetFiltersReturnsNonEmptyArray(): void
{
$filters = $this->extension->getFilters();
$this->assertNotEmpty($filters);
$this->assertContainsOnlyInstancesOf(TwigFilter::class, $filters);
}
public function testGetFunctionsContainsExpectedNames(): void
{
$names = array_map(
fn(TwigFunction $f) => $f->getName(),
$this->extension->getFunctions()
);
$expected = [
'do_action', 'apply_filters', 'wp_nonce_field', 'fn',
'wc_get_cart_url', 'wc_get_checkout_url', 'wc_get_template',
'wc_print_notices', 'woocommerce_form_field',
];
foreach ($expected as $name) {
$this->assertContains($name, $names, "Missing Twig function: {$name}");
}
}
public function testGetFiltersContainsExpectedNames(): void
{
$names = array_map(
fn(TwigFilter $f) => $f->getName(),
$this->extension->getFilters()
);
$expected = [
'esc_html', 'esc_attr', 'esc_url',
'sanitize_title', 'wpautop', 'wp_kses_post',
'wptexturize', 'do_shortcode',
];
foreach ($expected as $name) {
$this->assertContains($name, $names, "Missing Twig filter: {$name}");
}
}
// ── callFunction() whitelist ────────────────────────────────
public function testCallFunctionWithAllowedFunctionSucceeds(): void
{
// Define a stub for an allowed function.
Functions\when('_n')->alias(fn($single, $plural, $count) => $count === 1 ? $single : $plural);
$result = $this->extension->callFunction('_n', 'item', 'items', 1);
$this->assertSame('item', $result);
}
public function testCallFunctionWithDisallowedFunctionThrows(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('is not allowed');
$this->extension->callFunction('exec', 'whoami');
}
public function testCallFunctionWithNonExistentAllowedFunctionThrows(): void
{
// 'WC' is allowed but won't exist in the test environment.
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not exist');
$this->extension->callFunction('WC');
}
// ── doAction() ──────────────────────────────────────────────
public function testDoActionCapturesOutput(): void
{
Functions\when('do_action')->alias(function (string $tag): void {
echo "<div>{$tag}</div>";
});
$result = $this->extension->doAction('woocommerce_before_cart');
$this->assertSame('<div>woocommerce_before_cart</div>', $result);
}
public function testDoActionReturnsEmptyWhenNoOutput(): void
{
Functions\when('do_action')->justReturn(null);
$result = $this->extension->doAction('some_silent_hook');
$this->assertSame('', $result);
}
// ── applyFilters() ──────────────────────────────────────────
public function testApplyFiltersDelegatesToWordPress(): void
{
Functions\when('apply_filters')->alias(function (string $tag, ...$args) {
return strtoupper($args[0]);
});
$result = $this->extension->applyFilters('the_title', 'hello');
$this->assertSame('HELLO', $result);
}
// ── callUserFunc() ──────────────────────────────────────────
public function testCallUserFuncCapturesCallbackOutput(): void
{
$result = $this->extension->callUserFunc(function (): void {
echo 'tab-content';
});
$this->assertSame('tab-content', $result);
}
public function testCallUserFuncPassesArguments(): void
{
$result = $this->extension->callUserFunc(function (string $key): void {
echo "key={$key}";
}, 'description');
$this->assertSame('key=description', $result);
}
// ── setupProductData() ──────────────────────────────────────
public function testSetupProductDataSetsGlobalProduct(): void
{
$product = new \WC_Product(99);
Functions\when('get_post')->justReturn((object) ['ID' => 99]);
Functions\when('setup_postdata')->justReturn(true);
$result = $this->extension->setupProductData($product);
$this->assertSame('', $result);
$this->assertSame($product, $GLOBALS['product']);
unset($GLOBALS['product'], $GLOBALS['post']);
}
public function testSetupProductDataSkipsPostdataWhenPostNotFound(): void
{
$product = new \WC_Product(1);
Functions\when('get_post')->justReturn(null);
$result = $this->extension->setupProductData($product);
$this->assertSame('', $result);
$this->assertSame($product, $GLOBALS['product']);
unset($GLOBALS['product']);
}
// ── Output-capture wrappers ─────────────────────────────────
public function testWcPrintNoticesCapturesOutput(): void
{
Functions\when('wc_print_notices')->alias(function (): void {
echo '<div class="woocommerce-message">Notice</div>';
});
$result = $this->extension->wcPrintNotices();
$this->assertStringContainsString('woocommerce-message', $result);
}
public function testWcDisplayItemMetaReturnsString(): void
{
$item = new \stdClass();
Functions\when('wc_display_item_meta')->alias(function ($item, array $args): string {
return $args['echo'] === false ? '<span>meta</span>' : '';
});
$result = $this->extension->wcDisplayItemMeta($item, []);
$this->assertSame('<span>meta</span>', $result);
}
public function testWcQueryStringFormFieldsReturnsHtml(): void
{
Functions\when('wc_query_string_form_fields')->alias(function ($values, array $exclude, string $key, bool $return): string {
return $return ? '<input type="hidden">' : '';
});
$result = $this->extension->wcQueryStringFormFields(null, [], '');
$this->assertSame('<input type="hidden">', $result);
}
public function testWoocommerceFormFieldReturnsHtml(): void
{
Functions\when('woocommerce_form_field')->alias(function (string $key, array $args, $value): string {
return $args['return'] === true ? "<p class=\"form-row\">{$key}</p>" : '';
});
$result = $this->extension->woocommerceFormField('billing_first_name', [], null);
$this->assertStringContainsString('billing_first_name', $result);
}
public function testWcGetTemplateCapturesOutput(): void
{
Functions\when('wc_get_template')->alias(function (string $name): void {
echo "<div>{$name}</div>";
});
$result = $this->extension->wcGetTemplate('order/order-details.php', []);
$this->assertSame('<div>order/order-details.php</div>', $result);
}
}

14
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
/**
* PHPUnit bootstrap file.
*
* Loads Composer autoloader and class stubs required
* for unit testing outside of WordPress/WooCommerce.
*/
// Composer autoloader (loads WcBootstrap\* classes + Brain\Monkey).
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Class stubs (WordPress / WooCommerce / parent theme).
require_once __DIR__ . '/Stubs/TwigService.php';
require_once __DIR__ . '/Stubs/WcProduct.php';