Files
archived-frankenphp/caddy/config_test.go
Kévin Dunglas 225ca409d3 feat: hot reload (#2031)
This patch brings hot reloading capabilities to PHP apps: in
development, the browser will automatically refresh the page when any
source file changes!
It's similar to HMR in JavaScript.

It is built on top of [the watcher
mechanism](https://frankenphp.dev/docs/config/#watching-for-file-changes)
and of the [Mercure](https://frankenphp.dev/docs/mercure/) integration.

Each time a watched file is modified, a Mercure update is sent, giving
the ability to the client to reload the page, or part of the page
(assets, images...).

Here is an example implementation:

```caddyfile
root ./public


mercure {
      subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
      anonymous
}

php_server {
      hot_reload
}
```

```php
<?php
header('Content-Type: text/html');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>Test</title>
<script>
    const es = new EventSource('<?=$_SERVER['FRANKENPHP_HOT_RELOAD']?>');
    es.onmessage = () => location.reload();
</script>
</head>
<body>
Hello
```

I plan to create a helper JS library to handle more advanced cases
(reloading CSS, JS, etc), similar to [HotWire
Spark](https://github.com/hotwired/spark). Be sure to attend my
SymfonyCon to learn more!

There is still room for improvement:

- Provide an option to only trigger the update without reloading the
worker for some files (ex, images, JS, CSS...)
- Support classic mode (currently, only the worker mode is supported)
- Don't reload all workers when only the files used by one change

However, this PR is working as-is and can be merged as a first step.

This patch heavily refactors the watcher module. Maybe it will be
possible to extract it as a standalone library at some point (would be
useful to add a similar feature but not tight to PHP as a Caddy module).

---------

Signed-off-by: Kévin Dunglas <kevin@dunglas.fr>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 14:29:18 +01:00

223 lines
7.3 KiB
Go

package caddy
import (
"testing"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/stretchr/testify/require"
)
func TestModuleWorkerDuplicateFilenamesFail(t *testing.T) {
// Create a test configuration with duplicate worker filenames
configWithDuplicateFilenames := `
{
php {
worker {
file worker-with-env.php
num 1
}
worker {
file worker-with-env.php
num 2
}
}
}`
// Parse the configuration
d := caddyfile.NewTestDispenser(configWithDuplicateFilenames)
module := &FrankenPHPModule{}
// Unmarshal the configuration
err := module.UnmarshalCaddyfile(d)
// Verify that an error was returned
require.Error(t, err, "Expected an error when two workers in the same module have the same filename")
require.Contains(t, err.Error(), "must not have duplicate filenames", "Error message should mention duplicate filenames")
}
func TestModuleWorkersWithDifferentFilenames(t *testing.T) {
// Create a test configuration with different worker filenames
configWithDifferentFilenames := `
{
php {
worker ../testdata/worker-with-env.php
worker ../testdata/worker-with-counter.php
}
}`
// Parse the configuration
d := caddyfile.NewTestDispenser(configWithDifferentFilenames)
module := &FrankenPHPModule{}
// Unmarshal the configuration
err := module.UnmarshalCaddyfile(d)
// Verify that no error was returned
require.NoError(t, err, "Expected no error when two workers in the same module have different filenames")
// Verify that both workers were added to the module
require.Len(t, module.Workers, 2, "Expected two workers to be added to the module")
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "First worker should have the correct filename")
require.Equal(t, "../testdata/worker-with-counter.php", module.Workers[1].FileName, "Second worker should have the correct filename")
}
func TestModuleWorkersDifferentNamesSucceed(t *testing.T) {
// Create a test configuration with a worker name
configWithWorkerName1 := `
{
php_server {
worker {
name test-worker-1
file ../testdata/worker-with-env.php
num 1
}
}
}`
// Parse the first configuration
d1 := caddyfile.NewTestDispenser(configWithWorkerName1)
app := &FrankenPHPApp{}
module1 := &FrankenPHPModule{}
// Unmarshal the first configuration
err := module1.UnmarshalCaddyfile(d1)
require.NoError(t, err, "First module should be configured without errors")
// Create a second test configuration with a different worker name
configWithWorkerName2 := `
{
php_server {
worker {
name test-worker-2
file ../testdata/worker-with-env.php
num 1
}
}
}`
// Parse the second configuration
d2 := caddyfile.NewTestDispenser(configWithWorkerName2)
module2 := &FrankenPHPModule{}
// Unmarshal the second configuration
err = module2.UnmarshalCaddyfile(d2)
// Verify that no error was returned
require.NoError(t, err, "Expected no error when two workers have different names")
_, err = app.addModuleWorkers(module1.Workers...)
require.NoError(t, err, "Expected no error when adding the first module workers")
_, err = app.addModuleWorkers(module2.Workers...)
require.NoError(t, err, "Expected no error when adding the second module workers")
// Verify that both workers were added
require.Len(t, app.Workers, 2, "Expected two workers in the app")
require.Equal(t, "m#test-worker-1", app.Workers[0].Name, "First worker should have the correct name")
require.Equal(t, "m#test-worker-2", app.Workers[1].Name, "Second worker should have the correct name")
}
func TestModuleWorkerWithEnvironmentVariables(t *testing.T) {
// Create a test configuration with environment variables
configWithEnv := `
{
php {
worker {
file ../testdata/worker-with-env.php
num 1
env APP_ENV production
env DEBUG true
}
}
}`
// Parse the configuration
d := caddyfile.NewTestDispenser(configWithEnv)
module := &FrankenPHPModule{}
// Unmarshal the configuration
err := module.UnmarshalCaddyfile(d)
// Verify that no error was returned
require.NoError(t, err, "Expected no error when configuring a worker with environment variables")
// Verify that the worker was added to the module
require.Len(t, module.Workers, 1, "Expected one worker to be added to the module")
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename")
// Verify that the environment variables were set correctly
require.Len(t, module.Workers[0].Env, 2, "Expected two environment variables")
require.Equal(t, "production", module.Workers[0].Env["APP_ENV"], "APP_ENV should be set to production")
require.Equal(t, "true", module.Workers[0].Env["DEBUG"], "DEBUG should be set to true")
}
func TestModuleWorkerWithWatchConfiguration(t *testing.T) {
// Create a test configuration with watch directories
configWithWatch := `
{
php {
worker {
file ../testdata/worker-with-env.php
num 1
watch
watch ./src/**/*.php
watch ./config/**/*.yaml
}
}
}`
// Parse the configuration
d := caddyfile.NewTestDispenser(configWithWatch)
module := &FrankenPHPModule{}
// Unmarshal the configuration
err := module.UnmarshalCaddyfile(d)
// Verify that no error was returned
require.NoError(t, err, "Expected no error when configuring a worker with watch directories")
// Verify that the worker was added to the module
require.Len(t, module.Workers, 1, "Expected one worker to be added to the module")
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename")
// Verify that the watch directories were set correctly
require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns")
require.Equal(t, defaultWatchPattern, module.Workers[0].Watch[0], "First watch pattern should be the default")
require.Equal(t, "./src/**/*.php", module.Workers[0].Watch[1], "Second watch pattern should match the configuration")
require.Equal(t, "./config/**/*.yaml", module.Workers[0].Watch[2], "Third watch pattern should match the configuration")
}
func TestModuleWorkerWithCustomName(t *testing.T) {
// Create a test configuration with a custom worker name
configWithCustomName := `
{
php {
worker {
file ../testdata/worker-with-env.php
num 1
name custom-worker-name
}
}
}`
// Parse the configuration
d := caddyfile.NewTestDispenser(configWithCustomName)
module := &FrankenPHPModule{}
app := &FrankenPHPApp{}
// Unmarshal the configuration
err := module.UnmarshalCaddyfile(d)
// Verify that no error was returned
require.NoError(t, err, "Expected no error when configuring a worker with a custom name")
// Verify that the worker was added to the module
require.Len(t, module.Workers, 1, "Expected one worker to be added to the module")
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename")
// Verify that the worker was added to app.Workers with the m# prefix
module.Workers, err = app.addModuleWorkers(module.Workers...)
require.NoError(t, err, "Expected no error when adding the worker to the app")
require.Equal(t, "m#custom-worker-name", module.Workers[0].Name, "Worker should have the custom name, prefixed with m#")
require.Equal(t, "m#custom-worker-name", app.Workers[0].Name, "Worker should have the custom name, prefixed with m#")
}