Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,9 @@ jobs:

- name: Run tests with coverage
run: |
echo "🧪 Running tests with coverage..."
echo "🧪 Running tests with coverage (gate: 100% lines/elements, REQ-TEST-003)..."
rm -rf .phpunit.cache
composer test-coverage
composer test-coverage-100
if [ $? -ne 0 ]; then
echo "❌ ERROR: Tests failed"
exit 1
Expand All @@ -245,3 +245,31 @@ jobs:
with:
files: coverage.xml
fail_ci_if_error: false

frontend:
name: Frontend (Vitest coverage gate)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Setup pnpm
# Version comes from package.json "packageManager".
uses: pnpm/action-setup@v6

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '22'

- name: Install JS dependencies
run: pnpm install --frozen-lockfile

- name: Test with coverage (REQ-TEST-003 thresholds in vitest.config.ts)
run: |
set -o pipefail
pnpm run test:coverage | tee coverage-ts.txt
./.scripts/ts-coverage-percent.sh coverage-ts.txt

- name: Build assets
run: pnpm run build
61 changes: 61 additions & 0 deletions .scripts/check-coverage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

/**
* Checks that coverage.xml meets the minimum coverage threshold (default 100%).
* Exit code 0 if coverage >= threshold, 1 otherwise.
*
* Usage: php .scripts/check-coverage.php [coverage.xml] [--min-percent=N]
*/
$coverageFile = $argv[1] ?? 'coverage.xml';
$minPercent = 100;
foreach (array_slice($argv, 2) as $arg) {
if (str_starts_with($arg, '--min-percent=')) {
$minPercent = (int) substr($arg, strlen('--min-percent='));
break;
}
}

if (!is_file($coverageFile)) {
fwrite(\STDERR, "Coverage file not found: {$coverageFile}\n");
exit(2);
}

$xml = @simplexml_load_file($coverageFile);
if ($xml === false) {
fwrite(\STDERR, "Invalid XML in {$coverageFile}\n");
exit(2);
}

$projectMetrics = $xml->project->metrics;
if (!$projectMetrics) {
fwrite(\STDERR, "No project metrics in {$coverageFile}\n");
exit(2);
}

$statements = (int) (string) $projectMetrics['statements'];
$coveredStatements = (int) (string) $projectMetrics['coveredstatements'];
$elements = (int) (string) $projectMetrics['elements'];
$coveredElements = (int) (string) $projectMetrics['coveredelements'];

$stmtPercent = $statements > 0 ? round($coveredStatements / $statements * 100, 2) : 100.0;
$elemPercent = $elements > 0 ? round($coveredElements / $elements * 100, 2) : 100.0;

echo sprintf(
'Coverage: statements %d/%d (%.2f%%), elements %d/%d (%.2f%%)' . \PHP_EOL,
$coveredStatements,
$statements,
$stmtPercent,
$coveredElements,
$elements,
$elemPercent,
);

if ($stmtPercent < $minPercent || $elemPercent < $minPercent) {
fwrite(\STDERR, sprintf("Coverage below %d%% threshold.\n", $minPercent));
exit(1);
}

echo "Coverage threshold {$minPercent}% met.\n";
exit(0);
102 changes: 102 additions & 0 deletions .scripts/validate-translations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
* Validates the bundle translation catalogues (REQ-MAKE-004 / REQ-I18N-002 / REQ-I18N-003).
*
* - every file under src/Resources/translations/ is a NowoTagInputBundle.<locale>.yaml catalogue (no other domain);
* - every required locale exists and parses as a YAML map of scalar messages (nested maps are flattened to dotted keys);
* - every locale has exactly the keys of the reference locale (en), and no message is empty.
*
* Exit code 0 when everything is valid, 1 otherwise (errors on STDERR).
*/
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;

require dirname(__DIR__) . '/vendor/autoload.php';

$domain = 'NowoTagInputBundle';
$requiredLocales = ['en', 'es', 'fr', 'de', 'it', 'pt', 'nl'];
$referenceLocale = 'en';
$dir = dirname(__DIR__) . '/src/Resources/translations';

/**
* @param array<mixed> $tree
*
* @return array<string, mixed>
*/
$flatten = static function (array $tree, string $prefix = '') use (&$flatten): array {
$flat = [];
foreach ($tree as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (is_array($value)) {
$flat += $flatten($value, $path);
continue;
}
$flat[$path] = $value;
}

return $flat;
};

$errors = [];
$catalogues = [];

foreach (glob($dir . '/*') ?: [] as $file) {
if (preg_match('/^' . preg_quote($domain, '/') . '\.[a-z]{2}(?:_[A-Z]{2})?\.yaml$/', basename($file)) !== 1) {
$errors[] = sprintf('Unexpected catalogue %s: only %s.<locale>.yaml files are allowed (REQ-I18N-003).', basename($file), $domain);
}
}

foreach ($requiredLocales as $locale) {
$file = sprintf('%s/%s.%s.yaml', $dir, $domain, $locale);
if (!is_file($file)) {
$errors[] = sprintf('Missing catalogue: %s', $file);
continue;
}

try {
$parsed = Yaml::parseFile($file);
} catch (ParseException $e) {
$errors[] = sprintf('Invalid YAML in %s: %s', basename($file), $e->getMessage());
continue;
}

if (!is_array($parsed)) {
$errors[] = sprintf('Invalid catalogue (not a map): %s', basename($file));
continue;
}

$messages = $flatten($parsed);
foreach ($messages as $key => $message) {
if (!is_string($message) || trim($message) === '') {
$errors[] = sprintf('Empty or non-string message "%s" in %s', $key, basename($file));
}
}
$catalogues[$locale] = array_keys($messages);
}

$reference = $catalogues[$referenceLocale] ?? [];
foreach ($catalogues as $locale => $keys) {
if ($locale === $referenceLocale) {
continue;
}
$missing = array_diff($reference, $keys);
$extra = array_diff($keys, $reference);
if ($missing !== []) {
$errors[] = sprintf('Key parity failed for %s — missing: %s', $locale, implode(', ', $missing));
}
if ($extra !== []) {
$errors[] = sprintf('Key parity failed for %s — extra: %s', $locale, implode(', ', $extra));
}
}

if ($errors !== []) {
fwrite(STDERR, implode("\n", $errors) . "\n");
// @igor-ignore - CLI script, not a long-running worker path
exit(1);
}

printf("OK — %d locales, %d keys (domain %s)\n", count($catalogues), count($reference), $domain);
16 changes: 11 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ assets: ensure-up
test: ensure-up
$(COMPOSE) exec -T $(SERVICE_PHP) composer test

# REQ-TEST-003: fails when PHPUnit fails or Clover line/element coverage is below 100% (exit status kept through tee).
test-coverage: ensure-up
$(COMPOSE) exec -T $(SERVICE_PHP) composer test-coverage | tee coverage-php.txt
bash -o pipefail -c '$(COMPOSE) exec -T $(SERVICE_PHP) composer test-coverage-100 | tee coverage-php.txt'
./.scripts/php-coverage-percent.sh coverage-php.txt

# REQ-TEST-003: fails when Vitest coverage is under the vitest.config.ts thresholds; exit status kept (no pipe).
test-ts: ensure-up
$(COMPOSE) exec -T $(SERVICE_PHP) pnpm run test:coverage | tee coverage-ts.txt
./.scripts/ts-coverage-percent.sh coverage-ts.txt
@rc=0; $(COMPOSE) exec -T -e CI=true $(SERVICE_PHP) pnpm run test:coverage > coverage-ts.txt 2>&1 || rc=$$?; \
cat coverage-ts.txt; \
./.scripts/ts-coverage-percent.sh coverage-ts.txt; \
exit $$rc

assets-test: test-ts

Expand Down Expand Up @@ -114,7 +118,7 @@ igor: ensure-up
$(COMPOSE) exec -T php composer igor

# REQ-MAKE-002 order.
release-check: ensure-up check-no-cursor-coauthor check-open-prs check-twig-extra check-no-raw-html-form composer-sync cs-fix cs-check rector-dry phpstan igor test-coverage release-check-demos test-ts
release-check: ensure-up check-no-cursor-coauthor check-open-prs check-twig-extra check-no-raw-html-form composer-sync cs-fix cs-check rector-dry phpstan igor validate-translations test-coverage release-check-demos test-ts

clean:
rm -rf vendor coverage coverage-ts .phpunit.cache coverage-php.txt coverage-ts.txt
Expand All @@ -125,8 +129,10 @@ update: ensure-up
validate: ensure-up
$(COMPOSE) exec -T $(SERVICE_PHP) composer validate --strict

# REQ-MAKE-004: YAML lint + catalogue naming, required locales, non-empty messages and key parity against en.
validate-translations: ensure-up
$(COMPOSE) exec -T $(SERVICE_PHP) php -r 'require "vendor/autoload.php"; foreach (glob("src/Resources/translations/*.yaml") as $$f) { Symfony\Component\Yaml\Yaml::parseFile($$f); } echo "OK\n";'
$(COMPOSE) exec -T $(SERVICE_PHP) php vendor/bin/yaml-lint src/Resources/translations
$(COMPOSE) exec -T $(SERVICE_PHP) php .scripts/validate-translations.php


setup-hooks:
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ Current stable release: **v1.2.1** ([changelog](docs/CHANGELOG.md)).

## Tests and coverage

- PHP: 100%
- TS/JS: 96%
PHPUnit unit/integration tests (`make test`, `make test-coverage`) and Vitest + happy-dom tests for the TypeScript assets (`make test-ts`). Both suites run in CI with enforced gates (REQ-TEST-003): PHP Clover coverage must stay at 100% (`composer test-coverage-100`) and Vitest fails below the thresholds in `vitest.config.ts`.

- PHP: 100% (lines)
- TS/JS: 100% (lines; 98.66% branches)
- Python: N/A

## License
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
"scripts": {
"test": "@php vendor/bin/phpunit --color=always",
"test-coverage": "@php vendor/bin/phpunit --color=always --coverage-html coverage --coverage-clover coverage.xml --coverage-text",
"test-coverage-100": [
"@test-coverage",
"@php .scripts/check-coverage.php coverage.xml --min-percent=100"
],
"cs-check": "@php vendor/bin/php-cs-fixer fix --dry-run --diff",
"cs-fix": "@php vendor/bin/php-cs-fixer fix",
"rector": "@php vendor/bin/rector process",
Expand Down
2 changes: 1 addition & 1 deletion demo/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ verify:
demo-smoke:
@PORT=$$(grep "^PORT=" symfony8/.env 2>/dev/null | cut -d= -f2 | tr -d '\r'); \
[ -z "$$PORT" ] && PORT=$$(grep "^PORT=" symfony8/.env.example 2>/dev/null | cut -d= -f2 | tr -d '\r'); \
[ -z "$$PORT" ] && PORT=8011; \
[ -z "$$PORT" ] && PORT=8073; \
echo "Demo smoke: symfony8 (http://127.0.0.1:$$PORT/demo)"; \
$(MAKE) -C symfony8 up || exit 1; \
code=000; \
Expand Down
2 changes: 1 addition & 1 deletion demo/symfony8/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ APP_SECRET=change_this_secret_key_to_a_random_value

###> symfony/routing ###
# DEFAULT_URI: base URL for CLI route generation (must match PORT).
DEFAULT_URI=http://localhost:8011
DEFAULT_URI=http://localhost:8073
###< symfony/routing ###
4 changes: 2 additions & 2 deletions demo/symfony8/.env.test
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Test environment for Symfony 8 demo.
PORT=8011
PORT=8073

###> symfony/framework-bundle ###
APP_ENV=test
Expand All @@ -8,5 +8,5 @@ APP_SECRET=test_secret_for_ci_and_local_tests
###< symfony/framework-bundle ###

###> symfony/routing ###
DEFAULT_URI=http://localhost:8011
DEFAULT_URI=http://localhost:8073
###< symfony/routing ###
6 changes: 2 additions & 4 deletions demo/symfony8/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ up:
@$(MAKE) composer-install
$(DC) up -d
@echo "Waiting for FrankenPHP to stay up..."
@echo " make test-e2e Playwright e2e (REQ-DEMO-013)"
@echo " make demo-screenshots Refresh docs/images/demo/*.webp"
@ok=0; \
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
if $(DOCKER_COMPOSE) exec -T php true 2>/dev/null; then ok=1; break; fi; \
Expand Down Expand Up @@ -73,7 +71,7 @@ verify:
@if [ ! -f .env ]; then cp .env.example .env 2>/dev/null || true; fi
@port=$$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2 | tr -d '\r'); \
[ -z "$$port" ] && port=$$(grep '^PORT=' .env.example 2>/dev/null | cut -d= -f2 | tr -d '\r'); \
[ -z "$$port" ] && port=8011; \
[ -z "$$port" ] && port=8073; \
$(DOCKER_COMPOSE) up -d 2>/dev/null; sleep 8; \
code=$$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:$$port/demo 2>/dev/null || echo "000"); \
if [ "$$code" = "200" ]; then echo "OK (HTTP $$code)"; else echo "Failed (HTTP $$code)"; exit 1; fi
Expand All @@ -95,7 +93,7 @@ SERVICE_PHP := php
-include $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../../.scripts/Makefile.demo-update-deps.mk)

# Paste into demo/symfony8/Makefile (REQ-DEMO-013). Uses official Playwright image.
PLAYWRIGHT_IMAGE ?= mcr.microsoft.com/playwright:v1.63.0-jammy
PLAYWRIGHT_IMAGE ?= mcr.microsoft.com/playwright:v1.64.1-jammy

test-e2e: ensure-up
@mkdir -p e2e/node_modules
Expand Down
7 changes: 5 additions & 2 deletions demo/symfony8/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Tag Input Bundle — Demo (Symfony 8)

This demo runs with **FrankenPHP** (Caddy, HTTP on port 80). In **dev** (`APP_ENV=dev`), worker mode is disabled so each request runs in a new PHP process and **code/template changes are visible on refresh** without restarting the container.
This demo runs with **FrankenPHP** (Caddy, HTTP on port 80, published on the host as `PORT`, default **8073**). The runtime is
selected by `FRANKENPHP_MODE` in `.env` (`worker` by default, `classic` for one PHP process per request); with `watch` in the
worker Caddyfile, code/template changes are picked up on refresh. Dev/production start and troubleshooting:
[docs/DEMO-FRANKENPHP.md](../../docs/DEMO-FRANKENPHP.md).

## Quick start

```bash
make up
# Open http://localhost:8011 (or the PORT set in .env)
# Open http://localhost:8073 (or the PORT set in .env)
```

## Demo URLs
Expand Down
7 changes: 4 additions & 3 deletions demo/symfony8/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
container_name: tag-input-demo-symfony8-php
working_dir: /app
ports:
- "${PORT:-8011}:80"
- "${PORT:-8073}:80"
volumes:
- .:/app
- ../..:/var/tag-input-bundle
Expand All @@ -17,9 +17,10 @@ services:
- composer-cache:/root/.composer/cache
environment:
- COMPOSER_ALLOW_SUPERUSER=1
- APP_ENV=dev
- APP_DEBUG=1
- APP_ENV=${APP_ENV:-dev}
- APP_DEBUG=${APP_DEBUG:-1}
- APP_SECRET=${APP_SECRET:-demo-secret}
- PORT=${PORT:-8073}
- FRANKENPHP_MODE=${FRANKENPHP_MODE:-worker}
# Mitigate Docker/WSL DNS failures resolving repo.packagist.org (composer curl error 6).
dns:
Expand Down
2 changes: 1 addition & 1 deletion demo/symfony8/e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion demo/symfony8/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"install-browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.64.1",
"sharp": "^0.34.4"
}
}
2 changes: 1 addition & 1 deletion demo/symfony8/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';

// In Docker dev: app listens on 80 but is exposed as PORT on the host; set X-Forwarded-Port
// so the Web Profiler toolbar and generated URLs use the correct port (e.g. 8011).
// so the Web Profiler toolbar and generated URLs use the correct port (e.g. 8073).
if (($_SERVER['APP_ENV'] ?? '') === 'dev'
&& ($port = getenv('PORT')) !== false
&& ($_SERVER['SERVER_PORT'] ?? '80') === '80') {
Expand Down
Loading
Loading