Operations

Testing and E2E

Test suite structure, Playwright matrix, snapshot workflow, and PHP integration suite.

Testing & E2E

This document covers how to test Tagixo plugin integrations, custom modules, PropTypes, and full render pipelines — both with Pest (PHP) and Playwright (E2E).


What the plugin covers vs. what you test

Area Plugin provides You test
Custom modules registerModule() contract Correct HTML output, _styles CSS, empty-guard
PropTypes toCss() / toHtml() Enabled/disabled branches, merged output
Props Schema definition ->default() feeds drawer; render uses payload
Hooks afterFormSubmit, beforeRender, etc. Side-effects (mail sent, DB row created)
Form modules Field rendering, validation Submission happy-path and error state
Integration glue AdminPanelProvider, seeder Resource loads, page slug resolves
Builder API REST endpoints (/tagixo/builder/*) Auth, payload shape, persistence

TagixoTestCase

Ccast\Tagixo\Testing\TagixoTestCase ships inside ccast/tagixo and is available in your vendor/ after composer install. It boots the plugin's service provider, module registry, PropType registry, and Prop registry in isolation — no full HTTP stack, no DB seeding required.

Method Description
renderPage(Page $page): string Runs the full PageRenderer::renderWithLayout() pipeline and returns the final HTML string
makePage(array $attrs = []): Page Factory: creates and persists a tgx_pages row with sane defaults
makeLayout(array $attrs = []): Layout Factory: creates a tgx_layouts row
makeFormSchema(array $attrs = []): FormSchema Factory: creates a tgx_forms row
makeMailTemplate(array $attrs = []): MailTemplate Factory: creates a tgx_mail_templates row
makeGlobalVariable(array $attrs = []): GlobalVariable Factory: creates a tgx_global_variables row

The standard Laravel TestCase base is included, so fake(), actingAs(), Mail::fake(), HTTP assertions, and all other Laravel test utilities are available.


PHP / Pest integration tests

Module rendering

use Ccast\Tagixo\Testing\TagixoTestCase;
use App\Tagixo\Modules\CalloutModule;

uses(TagixoTestCase::class);

it('renders callout title', function () {
    $page = $this->makePage([
        'content' => json_encode([
            'type'     => 'section',
            'children' => [[
                'type'     => 'row',
                'children' => [[
                    'type'     => 'column',
                    'children' => [[
                        'type'   => 'module',
                        'module' => 'callout',
                        'props'  => ['title' => ['text' => 'Important notice']],
                    ]],
                ]],
            ]],
        ]),
    ]);

    $html = $this->renderPage($page);

    expect($html)
        ->toContain('Important notice')
        ->toContain('data-module="callout"');
});

it('applies accent colour from _styles', function () {
    $page = $this->makePage([
        'content' => json_encode([
            'type'    => 'section',
            '_styles' => [
                'elementMain' => [
                    'color' => ['value' => '#ff0000', 'enabled' => true],
                ],
            ],
            'children' => [],
        ]),
    ]);

    $html = $this->renderPage($page);

    expect($html)->toContain('color:#ff0000');
});

it('does not render when title is empty and hide_empty is enabled', function () {
    $page = $this->makePage([
        'content' => json_encode([
            'type'     => 'section',
            'children' => [[
                'type'     => 'row',
                'children' => [[
                    'type'     => 'column',
                    'children' => [[
                        'type'   => 'module',
                        'module' => 'callout',
                        'props'  => ['title' => ['text' => ''], 'hide_empty' => true],
                    ]],
                ]],
            ]],
        ]),
    ]);

    expect(fn () => $this->renderPage($page))->not->toThrow(\Throwable::class);
});

PropType toCss()

use App\Tagixo\PropTypes\GradientBorderPropType;

it('emits linear-gradient border when enabled', function () {
    $pt  = new GradientBorderPropType();
    $css = $pt->toCss([
        'enabled' => true,
        'color_a' => '#ff0000',
        'color_b' => '#0000ff',
        'width'   => 2,
    ]);

    expect($css)
        ->toContain('linear-gradient')
        ->toContain('border-width:2px');
});

it('returns empty string when disabled', function () {
    $pt  = new GradientBorderPropType();
    $css = $pt->toCss(['enabled' => false]);

    expect($css)->toBe('');
});

Full render pipeline via renderPage()

use Ccast\Tagixo\Testing\TagixoTestCase;

uses(TagixoTestCase::class);

it('runs the section → row → column → module hierarchy end-to-end', function () {
    $layout = $this->makeLayout([
        'header' => json_encode(['type' => 'section', 'children' => []]),
        'footer' => json_encode(['type' => 'section', 'children' => []]),
    ]);

    $page = $this->makePage([
        'layout_id' => $layout->id,
        'content'   => json_encode([
            'type' => 'section',
            'children' => [[
                'type' => 'row',
                'children' => [[
                    'type' => 'column',
                    'children' => [[
                        'type'   => 'module',
                        'module' => 'text',
                        'props'  => ['content' => '<p>Hello world</p>'],
                    ]],
                ]],
            ]],
        ]),
    ]);

    $html = $this->renderPage($page);

    expect($html)
        ->toContain('Hello world')
        ->toContain('tgx-section')
        ->toContain('tgx-row')
        ->toContain('tgx-column');
});

Form hook testing

use Illuminate\Support\Facades\Notification;
use App\Notifications\NewContactSubmission;
use App\Tagixo\Hooks\NotifyAdminOnSubmit;

it('dispatches admin notification on form submit', function () {
    Notification::fake();
    config(['tagixo.forms.admin_email' => 'admin@example.com']);

    (new NotifyAdminOnSubmit())->handle(
        ['name' => 'Jane', 'email' => 'jane@example.com', 'message' => 'Hello'],
        'contact',
    );

    Notification::assertSentOnDemand(
        NewContactSubmission::class,
        fn ($n, $channels, $notifiable) => $notifiable->routes['mail'] === 'admin@example.com',
    );
});

it('does not dispatch when form key does not match', function () {
    Notification::fake();

    (new NotifyAdminOnSubmit())->handle(
        ['email' => 'x@example.com'],
        'newsletter', // wrong form key
    );

    Notification::assertNothingSent();
});

Snapshot testing

use Ccast\Tagixo\Testing\TagixoTestCase;

uses(TagixoTestCase::class);

it('renders the Home page identically to the last snapshot', function () {
    $page = $this->makePage(['slug' => 'home', 'status' => 'published']);
    $html = $this->renderPage($page);

    expect($html)->toMatchSnapshot();
});

Caution: do not run --update-snapshots in bulk without reviewing the diff. A snapshot update should match an intentional change, not a regression.


Factory helpers

Method Table Key defaults
makePage(array) tgx_pages slug = random, status = draft
makeLayout(array) tgx_layouts empty header/footer
makeFormSchema(array) tgx_forms key = random
makeMailTemplate(array) tgx_mail_templates key = random
makeGlobalVariable(array) tgx_global_variables key + value required

Example — HTTP test using a factory page:

use Ccast\Tagixo\Testing\TagixoTestCase;

uses(TagixoTestCase::class);

it('resolves the page slug over HTTP and returns 200', function () {
    $layout = $this->makeLayout(['name' => 'Default']);

    $page = $this->makePage([
        'slug'      => 'about',
        'status'    => 'published',
        'layout_id' => $layout->id,
        'content'   => json_encode(['type' => 'page', 'children' => []]),
    ]);

    $this->get('/about')->assertOk();
});

Builder API tests

use Ccast\Tagixo\Testing\TagixoTestCase;
use App\Models\User;

uses(TagixoTestCase::class);

it('bootstrap endpoint returns required keys', function () {
    $page = $this->makePage(['status' => 'draft']);
    $user = User::factory()->create();

    $this->actingAs($user)
         ->getJson("/tagixo/builder/bootstrap?page_id={$page->id}")
         ->assertOk()
         ->assertJsonStructure(['page', 'catalog', 'propTypes', 'globalVariables']);
});

it('save endpoint persists updated content', function () {
    $page    = $this->makePage(['status' => 'draft']);
    $user    = User::factory()->create();
    $payload = [
        'page_id' => $page->id,
        'content' => ['type' => 'page', 'children' => []],
    ];

    $this->actingAs($user)
         ->postJson('/tagixo/builder/save', $payload)
         ->assertOk()
         ->assertJsonPath('status', 'ok');

    $this->assertDatabaseHas('tgx_pages', ['id' => $page->id]);
});

Playwright E2E

playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: process.env.APP_URL ?? 'http://localhost',
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Form submission test

import { test, expect } from '@playwright/test';

test('contact form submits successfully', async ({ page }) => {
  await page.goto('/contact');

  await page.fill('[name="name"]',    'Ada Lovelace');
  await page.fill('[name="email"]',   'ada@example.com');
  await page.fill('[name="message"]', 'Hello from Playwright');
  await page.click('[type="submit"]');

  await expect(page.locator('.tgx-form-success')).toBeVisible();
  await expect(page.locator('.tgx-form-success')).toContainText('Thank you');
});

Drag-and-drop builder test (canvas iframe)

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.goto('/admin/login');
  await page.fill('[name="email"]',    process.env.ADMIN_EMAIL!);
  await page.fill('[name="password"]', process.env.ADMIN_PASSWORD!);
  await page.click('[type="submit"]');
});

test('drags a Text module into the canvas', async ({ page }) => {
  await page.goto('/admin/pages/1/edit');

  // The builder canvas lives in an iframe
  const canvas = page.frameLocator('#tagixo-canvas-iframe');
  await canvas.locator('[data-vb-section]').waitFor();

  const textTile = page.locator('[data-catalog-type="text"]');
  const dropZone = canvas.locator('[data-vb-column]').first();

  await textTile.dragTo(dropZone);

  await expect(canvas.locator('[data-module="text"]')).toBeVisible();
});

Visual snapshot test

import { test, expect } from '@playwright/test';

test('homepage matches visual snapshot', async ({ page }) => {
  await page.goto('/');
  await page.waitForLoadState('networkidle');

  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    threshold: 0.01,
  });
});

Update baselines after intentional visual changes:

npx playwright test --update-snapshots

Visual snapshots are sensitive to font rendering differences across OS versions. Run baseline generation in the same environment used in CI, or use Docker for consistency.


CI integration

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  php-tests:
    name: PHP / Pest
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo, pdo_sqlite, pdo_pgsql

      - name: Install Composer dependencies
        env:
          COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TAGIXO_PAT }}"}}'
        run: composer install --no-interaction --prefer-dist

      - name: Run Pest
        run: vendor/bin/pest --ci

  playwright-tests:
    name: Playwright E2E
    runs-on: ubuntu-latest
    needs: php-tests
    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo, pdo_sqlite

      - name: Install Composer dependencies
        env:
          COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TAGIXO_PAT }}"}}'
        run: composer install --no-interaction --prefer-dist

      - name: Install Node dependencies
        run: npm ci

      - name: Build frontend assets
        run: npm run build

      - name: Prepare test database
        run: |
          cp .env.testing.example .env
          php artisan migrate --force
          php artisan db:seed --class=TestSeeder

      - name: Start app
        run: php artisan serve --port=8000 &
        env:
          APP_ENV: testing
          DB_CONNECTION: sqlite
          DB_DATABASE: database/testing.sqlite

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: npx playwright test
        env:
          APP_URL: http://localhost:8000
          ADMIN_EMAIL: admin@example.com
          ADMIN_PASSWORD: password

      - name: Upload Playwright report on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Running tests locally

# All Pest tests (via Sail)
./vendor/bin/sail composer test

# Single test file
./vendor/bin/sail artisan test --filter=CalloutModuleTest

# Pest with coverage
./vendor/bin/sail composer test -- --coverage --min=80

# Update PHP snapshot baselines
vendor/bin/pest --update-snapshots

# Playwright tests (app must be running)
npx playwright test

# Single Playwright spec
npx playwright test tests/e2e/contact-form.spec.ts

# Interactive step-through UI
npx playwright test --ui

# Update visual snapshot baselines
npx playwright test --update-snapshots

See also