Skip to content

Commit 553f57a

Browse files
lukinovecstanclgithub-actions[bot]Copilot
authored
[MINOR BC] BroadcastingConfigBootstrapper rewrite, bugfixes (#1448)
> Minor breaking change: BroadcastingConfigBootstrapper::$broadcaster property removed, we now just use the default broadcaster for setting mappings based on a preset ### Broadcasting auth route problem (`Broadcast` facade always uses central `BroadcastManager`) > Note: Tested with Pusher and Reverb. Also, this is the primary problem that this PR solves. When using `BroadcastingConfigBootstrapper` for broadcasting on private channels with multiple broadcasting apps (= each tenant has its own Pusher/Reverb/... app and credentials), the auth requests sent to the broadcasting server use the central broadcasting credentials. This is because the `Broadcast` facade (used in `BroadcastController::authenticate` like `Broadcast::auth($request)` to authorize the channels **and then retrieve the auth key that's then sent to the broadcasting server**) keeps using the `BroadcastManager` instance resolved in the central context instead of the tenant `BroadcastManager`. Calling `withBroadcasting()` in `bootstrap/app.php` results in calling `Broadcast::routes()` in the central context, which resolves and stores the central `BroadcastManager` (which is never cleared, and is used in tenant context). Clearing the facade's resolved instance (`Illuminate\Contracts\Broadcasting\Factory`) in `BroadcastingConfigBootstrapper::bootstrap()` forces the facade to re-resolve `Factory` (= `BroadcastManager`) on the next use, so the next `Broadcast::auth()` call uses the tenant `BroadcastManager` in the tenant context (and clearing the resolved instance in `revert()` makes the `Broadcast` facade use the central `BroadcastManager` again), and the credentials from the current config will be used for authentication. ### Central `BroadcastManager` doesn't pass custom driver creators to the tenant manager When registering a custom driver creator (`app(BroadcastManager::class)->extend('custom-driver', fn($app, $config) => new CustomBroadcaster(...))`) in the central context (e.g. in `BroadcastServiceProvider`/`AppServiceProvider`, or in our case, in the tests), the creator won't be available in the tenant context. Calling e.g. `app(BroadcastManager::class)->driver('custom-driver')` in the tenant context (if the creator was registered only in the central context) would throw `InvalidArgumentException` ("Driver [custom-driver] is not supported."). To fix that, register the original (central) `BroadcastManager`'s custom creators in the new `BroadcastManager` in `BroadcastingConfigBootstrapper::bootstrap()`. > Note: This was always a problem, the tests just never caught it. The original test where we used a custom driver creator actually registered the same creator on each context switch (see the `$registerTestingBroadcaster()` calls in the removed `BroadcastingTest` file) -- if the creator was registered just once (which is what we do now, after the fix), the test would fail. The test registered the driver creator repeatedly which worked around custom creators not persisting after switching between contexts. ### Central `Broadcaster` instance bound in tenant context > Note: This section describes the problem with resolving/injecting the `Broadcaster` contract directly (via DI or `app(Broadcaster::class)`). The `extend()` call that fixes it also plays a second, bigger role -- see the "How `BroadcastingConfigBootstrapper::bootstrap()` works" section below. The problem was that resolving `Illuminate\Contracts\Broadcasting\Broadcaster::class` in tenant context returned the broadcaster instance from the central context (Laravel binds the contract as a singleton, resolved from the default driver's config as it was at resolution time -- before tenancy was initialized). Fixed by making `Broadcaster::class` resolve to the current `BroadcastManager`'s default broadcaster (= the tenant broadcaster in tenant context) via `app->extend()`. Since the manager caches its broadcasters (see "Broadcasters are resolved once per tenancy initialization" below), `app(Broadcaster::class)` and `Broadcast::driver()` return the same instance in every context -- same as Laravel's default behavior. ### How `BroadcastingConfigBootstrapper::bootstrap()` works (the `extend()` calls) Both `extend()` calls run immediately during `bootstrap()`, not lazily on some later resolution -- `extend()` executes the closure right away when the extended singleton is already resolved, and both singletons are resolved at the top of `bootstrap()` (where we save the central instances for `revert()`). Since the singletons are already resolved, the container also doesn't store the closures as extenders -- they run once during `bootstrap()` and that's it. Once `revert()` restores the original instances, resolving either singleton again (even after a `forgetInstance()`) goes purely through the original bindings. So when tenancy initializes: 1. `setConfig()` maps the tenant's properties to the broadcasting config. 2. The `BroadcastManager` extend swaps the bound manager for a fresh one with an empty driver cache and passes the central manager's custom driver creators to it. 3. The `Broadcaster` contract extend calls `connection()` on the tenant manager, which resolves the default broadcaster using the updated (tenant) config and caches it as the manager's default driver. The central broadcaster's auth properties -- the channel auth closures, their options, and the authenticated user callback -- are then copied onto this tenant broadcaster. 4. `Broadcast::clearResolvedInstance()` makes the facade re-resolve on the next call, so it returns the tenant manager instead of the stale central one. When `/broadcasting/auth` gets hit later, `Broadcast::auth()` goes through the tenant manager's `driver()`, which returns the broadcaster cached in step 3 (built with the tenant credentials, using the copied channel auth closures). This means the `Broadcaster` contract extend isn't just for code that resolves or injects the contract directly -- it's also what makes channel auth work in tenant context. Without it, `app(Broadcaster::class)` would keep returning the stale central broadcaster, and the tenant broadcaster's `$channels` would stay empty, so `Broadcast::auth()` would throw a 403 for every channel registered in the central context. The same goes for user authentication -- `resolveAuthenticatedUser()` has no fallback, so without the copied authenticated user callback (registered via `Broadcast::resolveAuthenticatedUserUsing()`), `/broadcasting/user-auth` would throw a 403 in tenant context. `revert()` restores the original central manager and broadcaster instances via `instance()` (they're saved at the top of `bootstrap()` and nothing touches them while tenancy is initialized) and clears the facade's resolved instance again. ### Broadcasters are resolved once per tenancy initialization, `TenancyBroadcastManager` is removed Originally, `TenancyBroadcastManager` re-resolved the broadcasters listed in its `$tenantBroadcasters` static property on every retrieval. Earlier, we considered that good since it meant direct config changes in tenant context were picked up immediately. [As discussed in the review](#1448 (comment)), there's probably no real use case for that, and the re-resolving actually caused a subtle bug: channels registered via `Broadcast::channel()` in tenant context got silently lost on the next retrieval (e.g. during a `/broadcasting/auth` request), because each re-resolution started over from a copy of the central channels -- making the auth request fail with a 403 as if the channel was never registered. Now, `TenancyBroadcastManager` is removed entirely. `BroadcastingConfigBootstrapper` binds a fresh, base `BroadcastManager` with no cached broadcasters, so the broadcasters get resolved using the tenant's credentials on first use and stay cached (like in the parent manager) for the duration of the tenant's context. The central broadcaster's auth properties (the channel auth closures, their options, and the authenticated user callback) are copied to the tenant broadcaster directly in the `Broadcaster` contract's `extend()` closure -- since Laravel only ever uses the *default* broadcaster's channel auth closures for broadcasting auth (both `Broadcast::channel()` and `Broadcast::auth()` go through the default broadcaster), the properties only have to be copied to the default broadcaster. The channel auth closures are always *only* on the default broadcaster, even in plain Laravel. Every `Broadcast::channel()` call adds an entry to the same `$channels` array on the default broadcaster -- a channel with its own authorization rules is just one of those entries, so it gets copied along with the rest. The only way to register a closure on a non-default broadcaster is calling `Broadcast::driver('foobar')->channel(...)` explicitly, and nothing in Laravel's auth flow ever reads those (`/broadcasting/auth` always authenticates using the default broadcaster). This all means that: - `TenancyBroadcastManager` is removed. Its `$tenantBroadcasters` property's purpose was listing the broadcasters to re-resolve (and pass the central channel auth closures to) -- custom drivers now work without the need to configure anything, and the closure copying is a few lines in the bootstrapper instead of a manager override. - Channels registered via `Broadcast::channel()` in tenant context persist for the duration of that context. They don't leak into other tenants' contexts or into the central context. - For broadcasters to use updated credentials, tenancy has to be reinitialized. Direct broadcasting config changes made in tenant context aren't picked up by the broadcasters, and tenant property changes are only mapped to config in `bootstrap()`. An already-injected (= stale) `Broadcaster` instance additionally needs to be obtained again after reinitialization -- reinitializing swaps the bound instance, so a previously injected instance keeps using the old credentials. ### NOTE: Already-connected clients stop receiving broadcasts after a credential update Updating a tenant's credentials doesn't disconnect already-connected clients (tested with Reverb) -- they stay connected using the old key, but they stop receiving broadcasts, since broadcasts sent after the update are sent with the new credentials. The frontend has to reconnect using the new key (e.g. by refreshing the page). Meaning, clients can't be notified about the credential change through websockets themselves -- a broadcast sent after the update already uses the new credentials, so it won't reach clients connected with the old key. So for things like notifying clients in response to the credential changes, a different mechanism is needed. ### Credential map: overriding presets (BroadcastingConfigBootstrapper) Previously, credential mappings from `$mapPresets` overrode mappings defined in `$credentialsMap`. If someone used e.g. Pusher and wanted to override some of that preset's mappings, e.g. use 'pusher_app_key' instead of 'pusher_key' by specifying 'pusher_app_key' in `$credentialsMap`, the preset's mapping ('pusher_key') would still be used. Fixed that by reversing the `array_merge()` order in `BroadcastingConfigBootstrapper::__construct()`. ### **MINOR BC:** `BroadcastingConfigBootstrapper::$broadcaster` property removed `BroadcastingConfigBootstrapper::$broadcaster` determined which `$mapPresets` preset to apply instead of just using `broadcasting.default`. Its only effect was applying a preset for a connection other than the default -- a connection nothing resolves, so it did nothing useful. Set `$credentialsMap` directly if you need a non-default mapping. Removing it also let us drop the static mutation from the constructor, which used to overwrite the user-configured `$credentialsMap`. The preset merge now happens locally in `setConfig()`. ### Tests Deleted the BroadcastingTest file, moved the tests to appropriate bootstrapper test files. Added tests - for mapping tenant properties to broadcaster credentials (including keeping the central config values when a tenant doesn't have a mapped property, and reverting to the central credentials after a tenant with credential overrides) - for the rest of the changes mentioned above (including a regression test for the tenant-context channel registration bug) - for copying the channel options and the authenticated user callback along with the channel auth closures - for the bound `Broadcaster` and the manager's default broadcaster being the same instance in central and tenant contexts - for broadcasters that only implement the `Broadcaster` contract (instead of extending the abstract class), and for configuring which map preset is used via the `$broadcaster` property Also improved the existing tests. --------- Co-authored-by: Samuel Stancl <samuel@archte.ch> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 76e5f96 commit 553f57a

7 files changed

Lines changed: 482 additions & 305 deletions

phpstan.neon

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ parameters:
1515
ignoreErrors:
1616
- identifier: trait.unused
1717
- identifier: missingType.iterableValue
18-
-
19-
message: '#Spatie\\Invade\\Invader#'
20-
identifier: method.notFound
18+
#-
19+
# message: '#Spatie\\Invade\\Invader#'
20+
# identifier: method.notFound
2121
-
2222
message: '#Spatie\\Invade\\Invader#'
2323
identifier: property.notFound

src/Bootstrappers/BroadcastingConfigBootstrapper.php

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@
44

55
namespace Stancl\Tenancy\Bootstrappers;
66

7+
use Illuminate\Broadcasting\Broadcasters\Broadcaster;
78
use Illuminate\Broadcasting\BroadcastManager;
89
use Illuminate\Config\Repository;
9-
use Illuminate\Contracts\Broadcasting\Broadcaster;
10+
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
1011
use Illuminate\Foundation\Application;
12+
use Illuminate\Support\Facades\Broadcast;
1113
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
1214
use Stancl\Tenancy\Contracts\Tenant;
13-
use Stancl\Tenancy\Overrides\TenancyBroadcastManager;
1415

16+
/**
17+
* Maps tenant credentials to the broadcasting config and rebinds BroadcastManager
18+
* and Broadcaster so that broadcasters get resolved using the tenant credentials.
19+
*/
1520
class BroadcastingConfigBootstrapper implements TenancyBootstrapper
1621
{
1722
/**
@@ -21,14 +26,14 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper
2126
* [
2227
* 'config.key.name' => 'tenant_property',
2328
* ]
29+
*
30+
* $tenant->tenant_property will be mapped to config('config.key.name') when tenancy is initialized.
2431
*/
2532
public static array $credentialsMap = [];
2633

27-
public static string|null $broadcaster = null;
28-
2934
protected array $originalConfig = [];
3035
protected BroadcastManager|null $originalBroadcastManager = null;
31-
protected Broadcaster|null $originalBroadcaster = null;
36+
protected BroadcasterContract|null $originalBroadcaster = null;
3237

3338
public static array $mapPresets = [
3439
'pusher' => [
@@ -52,36 +57,97 @@ class BroadcastingConfigBootstrapper implements TenancyBootstrapper
5257
public function __construct(
5358
protected Repository $config,
5459
protected Application $app
55-
) {
56-
static::$broadcaster ??= $config->get('broadcasting.default');
57-
static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$broadcaster] ?? []);
58-
}
60+
) {}
5961

6062
public function bootstrap(Tenant $tenant): void
6163
{
6264
$this->originalBroadcastManager = $this->app->make(BroadcastManager::class);
63-
$this->originalBroadcaster = $this->app->make(Broadcaster::class);
65+
$this->originalBroadcaster = $this->app->make(BroadcasterContract::class);
6466

6567
$this->setConfig($tenant);
6668

67-
// Make BroadcastManager resolve to a custom BroadcastManager which makes the broadcasters use the tenant credentials
68-
$this->app->extend(BroadcastManager::class, function (BroadcastManager $broadcastManager) {
69-
return new TenancyBroadcastManager($this->app);
69+
// Make BroadcastManager resolve to a fresh manager with no cached broadcasters,
70+
// so that its broadcasters get resolved using the updated (tenant) broadcasting
71+
// config and stay cached for the duration of the tenant's context.
72+
$this->app->extend(BroadcastManager::class, function (BroadcastManager $centralManager) {
73+
$tenantManager = new BroadcastManager($this->app);
74+
75+
// Pass the custom driver creators registered in the central context to the new manager
76+
// so that custom drivers work in tenant context without having to re-register the creators manually.
77+
foreach (invade($centralManager)->customCreators as $driver => $creator) {
78+
$tenantManager->extend($driver, $creator);
79+
}
80+
81+
return $tenantManager;
7082
});
83+
84+
// Swap the currently bound Broadcaster singleton (resolved earlier with the central credentials)
85+
// for the tenant BroadcastManager's default broadcaster, so that anything resolving the Broadcaster
86+
// contract gets the same tenant broadcaster that the manager uses, instead of the stale central one.
87+
// The closure runs immediately (the extended singleton is already resolved), and it's also what makes
88+
// channel auth work in tenant context -- the broadcaster resolved here gets cached as the tenant
89+
// manager's default driver and receives the central broadcaster's auth properties (see copyAuthProperties()).
90+
$this->app->extend(BroadcasterContract::class, function (BroadcasterContract $centralBroadcaster) {
91+
$tenantBroadcaster = $this->app->make(BroadcastManager::class)->connection();
92+
93+
$this->copyAuthProperties($centralBroadcaster, $tenantBroadcaster);
94+
95+
return $tenantBroadcaster;
96+
});
97+
98+
// Extending the binding doesn't update the Broadcast facade's cached instance,
99+
// so clear it to make the facade re-resolve to the tenant BroadcastManager instead of the central
100+
// one — e.g. in the Broadcast::auth() call in BroadcastController (/broadcasting/auth).
101+
Broadcast::clearResolvedInstance();
102+
}
103+
104+
/**
105+
* Copy the channel and auth properties (the registered channel auth closures, their
106+
* options, and the authenticated user callback) from one broadcaster to another. A
107+
* freshly resolved broadcaster has none of these set, so without the copying, channel
108+
* auth and user auth would stop working (403) in tenant context.
109+
*
110+
* These properties are stored on the abstract Broadcaster class, not in the Broadcaster
111+
* contract, and they're stored in protected properties. Because of that, we have
112+
* to check that both broadcasters are instances of the abstract Broadcaster class and
113+
* use invade() to access the protected properties (for the $channels property, there
114+
* is a public accessor -- getChannels() -- but since invade is already used here,
115+
* we access the property directly for consistency).
116+
*/
117+
protected function copyAuthProperties(BroadcasterContract $from, BroadcasterContract $to): void
118+
{
119+
if (! $from instanceof Broadcaster || ! $to instanceof Broadcaster) {
120+
return;
121+
}
122+
123+
$fromState = invade($from);
124+
$toState = invade($to);
125+
126+
$toState->channels = $fromState->channels;
127+
$toState->channelOptions = $fromState->channelOptions;
128+
$toState->authenticatedUserCallback = $fromState->authenticatedUserCallback;
71129
}
72130

73131
public function revert(): void
74132
{
75-
// Change the BroadcastManager and Broadcaster singletons back to what they were before initializing tenancy
76-
$this->app->singleton(BroadcastManager::class, fn (Application $app) => $this->originalBroadcastManager);
77-
$this->app->singleton(Broadcaster::class, fn (Application $app) => $this->originalBroadcaster);
133+
// Revert the bound BroadcastManager and Broadcaster singletons back to their original state
134+
$this->app->instance(BroadcastManager::class, $this->originalBroadcastManager);
135+
$this->app->instance(BroadcasterContract::class, $this->originalBroadcaster);
136+
137+
// Clear the resolved Broadcast facade instance so that it gets re-resolved as the central BroadcastManager
138+
Broadcast::clearResolvedInstance();
78139

79140
$this->unsetConfig();
80141
}
81142

82143
protected function setConfig(Tenant $tenant): void
83144
{
84-
foreach (static::$credentialsMap as $configKey => $storageKey) {
145+
$credentialsMap = array_merge(
146+
static::$mapPresets[$this->config->get('broadcasting.default')] ?? [],
147+
static::$credentialsMap,
148+
);
149+
150+
foreach ($credentialsMap as $configKey => $storageKey) {
85151
$override = $tenant->$storageKey;
86152

87153
if (array_key_exists($storageKey, $tenant->getAttributes())) {

src/Overrides/TenancyBroadcastManager.php

Lines changed: 0 additions & 65 deletions
This file was deleted.

tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
1717
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
1818
use function Stancl\Tenancy\Tests\pest;
19+
use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
20+
use Illuminate\Support\Facades\Broadcast;
21+
use Illuminate\Support\Collection;
1922

2023
beforeEach(function () {
2124
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
@@ -137,3 +140,102 @@ protected function formatChannels(array $channels)
137140
expect(app(BroadcastManager::class)->driver())->toBe($broadcaster);
138141
expect(invade(app(BroadcastManager::class)->driver())->formatChannels($channelNames))->toEqual($channelNames);
139142
});
143+
144+
test('broadcasting channel helpers register channels correctly', function() {
145+
config([
146+
'broadcasting.default' => $driver = 'testing',
147+
'broadcasting.connections.testing.driver' => $driver,
148+
]);
149+
150+
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
151+
152+
Schema::create('users', function (Blueprint $table) {
153+
$table->increments('id');
154+
$table->string('name');
155+
$table->string('email')->unique();
156+
$table->string('password');
157+
$table->rememberToken();
158+
$table->timestamps();
159+
});
160+
161+
$centralUser = User::create(['name' => 'central', 'email' => 'test@central.cz', 'password' => 'test']);
162+
$tenant = Tenant::create();
163+
164+
migrateTenants();
165+
166+
tenancy()->initialize($tenant);
167+
168+
// Same ID as $centralUser
169+
$tenantUser = User::create(['name' => 'tenant', 'email' => 'test@tenant.cz', 'password' => 'test']);
170+
171+
tenancy()->end();
172+
173+
/** @var BroadcastManager $broadcastManager */
174+
$broadcastManager = app(BroadcastManager::class);
175+
176+
// Use a driver with no channels
177+
$broadcastManager->extend($driver, fn () => new NullBroadcaster);
178+
179+
$getChannels = fn (): Collection => $broadcastManager->driver($driver)->getChannels();
180+
181+
expect($getChannels())->toBeEmpty();
182+
183+
// Basic channel registration
184+
Broadcast::channel($channelName = 'user.{userName}', $channelClosure = function ($user, $userName) {
185+
return User::firstWhere('name', $userName)?->is($user) ?? false;
186+
});
187+
188+
// Check if the channel is registered
189+
$centralChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === $channelName);
190+
expect($centralChannelClosure)->not()->toBeNull();
191+
192+
// Channel closures work as expected (running in central context)
193+
expect($centralChannelClosure($centralUser, $centralUser->name))->toBeTrue();
194+
expect($centralChannelClosure($centralUser, $tenantUser->name))->toBeFalse();
195+
196+
// Register a tenant broadcasting channel (almost identical to the original channel, just able to accept the tenant key)
197+
tenant_channel($channelName, $channelClosure);
198+
199+
// Tenant channel registered – its name is correctly prefixed ("{tenant}.user.{userName}")
200+
$tenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName");
201+
expect($tenantChannelClosure)->toBe($centralChannelClosure);
202+
203+
// The tenant channels are prefixed with '{tenant}.'
204+
// They accept the tenant key, but their closures only run in tenant context when tenancy is initialized
205+
// The regular channels don't accept the tenant key, but they also respect the current context
206+
// The tenant key is used solely for the name prefixing – the closures can still run in the central context
207+
tenant_channel($channelName, $tenantChannelClosure = function ($user, $tenant, $userName) {
208+
return User::firstWhere('name', $userName)?->is($user) ?? false;
209+
});
210+
211+
// Retrieve the stored closure to verify that re-registering the channel replaced it
212+
// (asserting on $tenantChannelClosure wouldn't tell us what tenant_channel() actually stored)
213+
$reregisteredTenantChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === "{tenant}.$channelName");
214+
215+
expect($reregisteredTenantChannelClosure)
216+
->toBe($tenantChannelClosure)
217+
->not()->toBe($centralChannelClosure);
218+
219+
expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $centralUser->name))->toBeTrue();
220+
expect($reregisteredTenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse();
221+
222+
tenancy()->initialize($tenant);
223+
224+
// The channel closure runs in the tenant context
225+
// Only the tenant user is available
226+
expect($tenantChannelClosure($centralUser, $tenant->getTenantKey(), $tenantUser->name))->toBeFalse();
227+
expect($tenantChannelClosure($tenantUser, $tenant->getTenantKey(), $tenantUser->name))->toBeTrue();
228+
229+
// Use a new channel instance to delete the previously registered channels before testing the global_channel helper
230+
$broadcastManager->purge($driver);
231+
$broadcastManager->extend($driver, fn () => new NullBroadcaster);
232+
233+
expect($getChannels())->toBeEmpty();
234+
235+
// Global channel helper prefixes the channel name with 'global__'
236+
global_channel($channelName, $channelClosure);
237+
238+
// Channel prefixed with 'global__' found
239+
$foundChannelClosure = $getChannels()->first(fn ($closure, $name) => $name === 'global__' . $channelName);
240+
expect($foundChannelClosure)->not()->toBeNull();
241+
});

0 commit comments

Comments
 (0)