create-prisma-php-app 5.1.0-alpha.3 → 5.1.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +80 -33
  3. package/dist/AGENTS.md +59 -25
  4. package/dist/bootstrap.php +207 -187
  5. package/dist/index.js +2 -2
  6. package/dist/phpunit.xml +25 -0
  7. package/dist/postcss.config.js +4 -2
  8. package/dist/public/.htaccess +1 -1
  9. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  10. package/dist/settings/bs-config.ts +44 -1
  11. package/dist/settings/run-postcss.ts +205 -0
  12. package/dist/settings/run-tests.ts +35 -0
  13. package/dist/src/Lib/Auth/Auth.php +12 -25
  14. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  15. package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
  16. package/dist/src/Lib/Websocket/Socket.php +170 -0
  17. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  18. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  19. package/dist/src/Lib/Websocket/sockets.php +50 -0
  20. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  21. package/dist/src/app/globals.css +3 -1
  22. package/dist/src/app/layout.php +1 -1
  23. package/dist/tests/AuthTest.php +59 -0
  24. package/dist/tests/ConnectionManagerTest.php +277 -0
  25. package/dist/tests/CsrfTest.php +119 -0
  26. package/dist/tests/DeferComponentRootsTest.php +147 -0
  27. package/dist/tests/FeaturesTest.php +41 -0
  28. package/dist/tests/README.md +119 -0
  29. package/dist/tests/RpcWireContractTest.php +101 -0
  30. package/dist/tests/SocketPoolTest.php +69 -0
  31. package/dist/tests/SocketRegistryTest.php +77 -0
  32. package/dist/tests/SocketTest.php +124 -0
  33. package/dist/tests/SocketsRegistrationTest.php +40 -0
  34. package/dist/tests/Support/FakeConnection.php +62 -0
  35. package/dist/tests/Support/Features.php +38 -0
  36. package/dist/tests/Support/RequiresFeature.php +26 -0
  37. package/dist/tests/bootstrap.php +44 -0
  38. package/dist/ts/main.ts +5 -8
  39. package/dist/ts/tailwind-merge.ts +13 -0
  40. package/package.json +4 -4
  41. package/dist/public/js/pp-reactive-v2.js +0 -1
@@ -0,0 +1,119 @@
1
+ # App tests
2
+
3
+ PHPUnit tests for the **application** code — `src/Lib/**`, the app's wire
4
+ contracts, and `src/Lib/Websocket/sockets.php` registrations — not the Prisma
5
+ PHP framework under `vendor/tsnc/prisma-php` (that package carries its own
6
+ suite in its source repo).
7
+
8
+ ## The command
9
+
10
+ ```bash
11
+ npm run test
12
+ ```
13
+
14
+ That runs PHPUnit through `settings/run-tests.ts`, which uses the PHP binary
15
+ from `prisma-php.json` (`phpRootPathExe`) so the suite runs with the same PHP
16
+ the app runs on. It exits non-zero on any failure, so CI, a pre-commit hook,
17
+ or an agent knows exactly when something broke.
18
+
19
+ Narrow a run with PHPUnit's own flags after `--`:
20
+
21
+ ```bash
22
+ npm run test -- --filter CsrfTest
23
+ npm run test -- --filter testValidTokenRoundTrip
24
+ ```
25
+
26
+ Running PHPUnit directly also works: `php vendor/phpunit/phpunit/phpunit`
27
+ (configuration is picked up from `phpunit.xml` at the project root).
28
+
29
+ ## Layout
30
+
31
+ One file per surface, flat in `tests/`:
32
+
33
+ | File | Surface |
34
+ | --- | --- |
35
+ | `bootstrap.php` | Shared setup: autoloader, path constants, deterministic test env |
36
+ | `Support/` | Reusable fakes (`Tests\Support\...`, autoloaded via composer `autoload-dev`) |
37
+ | `Support/Features.php` | Feature flags read from `prisma-php.json` |
38
+ | `Support/RequiresFeature.php` | Skip guard for tests of optional features |
39
+ | `FeaturesTest.php` | The suite's own feature awareness mirrors `prisma-php.json` |
40
+ | `CsrfTest.php` | The `pp_csrf` cookie family and `X-CSRF-Token` validation |
41
+ | `RpcWireContractTest.php` | PulsePoint wire headers (`$isRpc` / `$isNavigation` / `$isWire`), including the pin that the legacy header stays dead |
42
+ | `DeferComponentRootsTest.php` | Server-deferred component roots: outermost `[pp-component]` body roots ship inside inert `<template pp-component>` wrappers |
43
+ | `SocketTest.php` | One socket connection: JSON frames, reserved error shape, close semantics |
44
+ | `SocketRegistryTest.php` | Named-socket registration and duplicate refusal |
45
+ | `SocketPoolTest.php` | Broadcast and pruning |
46
+ | `ConnectionManagerTest.php` | The full named-socket wire: handshake refusals, argument frame, auth, limits |
47
+ | `SocketsRegistrationTest.php` | The app's real `sockets.php` loads and names its sockets |
48
+ | `AuthTest.php` | JWT round trip, tamper/expiry/foreign-secret rejection |
49
+
50
+ ## The test environment is deterministic
51
+
52
+ `tests/bootstrap.php` does **not** load the real `.env`. It loads the composer
53
+ autoloader plus `settings/paths.php`, then sets deterministic defaults
54
+ (`APP_ENV=development`, a 32-byte `AUTH_SECRET`, `FUNCTION_CALL_SECRET`) only
55
+ when the variable is not already set — so the suite behaves the same on any
56
+ machine, never depends on real secrets, and CI can still override. The Prisma
57
+ PHP `bootstrap.php` at the project root (route resolution, rendering, CSRF
58
+ cookie issuing) is **not** booted; tests exercise app classes directly.
59
+
60
+ A test that needs a different env value must set it with `putenv(...)` and
61
+ restore the previous value in `tearDown()` (`PP\Env` reads `getenv()` first) —
62
+ see `ConnectionManagerTest::setEnv()` for the pattern.
63
+
64
+ ## Optional features are optional in the suite too
65
+
66
+ `prisma-php.json` decides which optional scaffolds exist (`websocket`, `mcp`,
67
+ `swaggerDocs`, `prisma`, ...). A test for an optional feature must guard on
68
+ the flag, or a freshly generated app without that feature fatals instead of
69
+ passing:
70
+
71
+ ```php
72
+ use Tests\Support\RequiresFeature;
73
+
74
+ final class MySocketTest extends TestCase
75
+ {
76
+ use RequiresFeature;
77
+
78
+ protected function setUp(): void
79
+ {
80
+ $this->requireFeature('websocket'); // FIRST line, before touching the scaffold
81
+ }
82
+ }
83
+ ```
84
+
85
+ When the flag is off the test reports itself as skipped with the reason and
86
+ the enable command — never as a failure. `tearDown()` still runs after a
87
+ skip, so teardown code must not assume the feature's classes exist (see
88
+ `SocketRegistryTest::resetRegistry()` for the `class_exists` guard).
89
+
90
+ The websocket suites (`SocketTest`, `SocketRegistryTest`, `SocketPoolTest`,
91
+ `ConnectionManagerTest`, `SocketsRegistrationTest`) are gated this way. Core
92
+ surfaces — CSRF, the RPC wire headers, auth — are always-on and never gated.
93
+
94
+ ## Conventions
95
+
96
+ - one `SomethingTest.php` per surface, in the `Tests` namespace; shared fakes
97
+ live in `tests/Support` under `Tests\Support`
98
+ - static registries (`SocketRegistry`) are reset in `setUp()`/`tearDown()` via
99
+ reflection so tests never depend on each other's order
100
+ - superglobals a test touches (`$_SERVER`, `$_COOKIE`, env) are backed up in
101
+ `setUp()` and restored in `tearDown()`
102
+ - wire tests assert **frames and close codes**, not internals:
103
+ `Tests\Support\FakeConnection` records everything the server sends, so a
104
+ test reads the conversation the browser would have seen
105
+ - expected framework failures are part of the contract — a refusal test
106
+ asserts the readable `{"error": "..."}` frame (or HTTP status), not just
107
+ "something failed"
108
+ - tests cover app-level code and the wire contracts the app depends on; do
109
+ not test Prisma PHP internals from here — those belong to the package
110
+ repo's own suite (`vendor/tsnc/prisma-php/tests` when vendored)
111
+
112
+ ## For AI agents
113
+
114
+ Read this file plus `node_modules/prisma-php/dist/docs/testing.md` before
115
+ adding or changing tests. When you add an app feature with logic worth
116
+ protecting — an exposed function's validation, a socket handler, an auth
117
+ rule — add the matching `*Test.php` here in the same change, and run
118
+ `npm run test` before declaring the work done. A red suite is a finding to
119
+ fix, not to silence.
@@ -0,0 +1,101 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use PP\Request;
9
+
10
+ /**
11
+ * The PulsePoint wire headers, as the app depends on them.
12
+ *
13
+ * `pp.rpc(...)` posts with `X-PP-RPC: true` + `X-PP-Function`; SPA navigation
14
+ * fetches with `X-PP-Navigation: true`; both carry `X-PulsePoint-Wire: true`.
15
+ * This is a complete migration — the legacy `PP-Wire-Request` header must NOT
16
+ * be recognized any more, and that is pinned here on purpose.
17
+ */
18
+ final class RpcWireContractTest extends TestCase
19
+ {
20
+ private array $serverBackup = [];
21
+
22
+ protected function setUp(): void
23
+ {
24
+ $this->serverBackup = $_SERVER;
25
+ }
26
+
27
+ protected function tearDown(): void
28
+ {
29
+ $_SERVER = $this->serverBackup;
30
+ }
31
+
32
+ private function initRequest(array $server): void
33
+ {
34
+ $_SERVER = array_merge([
35
+ 'HTTP_HOST' => 'example.test',
36
+ 'SCRIPT_NAME' => '/index.php',
37
+ 'REMOTE_ADDR' => '127.0.0.1',
38
+ ], $server);
39
+
40
+ Request::init();
41
+ }
42
+
43
+ public function testRpcPostIsDetected(): void
44
+ {
45
+ $this->initRequest([
46
+ 'REQUEST_METHOD' => 'POST',
47
+ 'CONTENT_TYPE' => 'application/json',
48
+ 'HTTP_X_PULSEPOINT_WIRE' => 'true',
49
+ 'HTTP_X_PP_RPC' => 'true',
50
+ 'HTTP_X_PP_FUNCTION' => 'saveProfile',
51
+ ]);
52
+
53
+ self::assertTrue(Request::$isWire);
54
+ self::assertTrue(Request::$isRpc);
55
+ self::assertFalse(Request::$isNavigation);
56
+ }
57
+
58
+ public function testNavigationGetIsWireButNotRpc(): void
59
+ {
60
+ $this->initRequest([
61
+ 'REQUEST_METHOD' => 'GET',
62
+ 'HTTP_X_PULSEPOINT_WIRE' => 'true',
63
+ 'HTTP_X_PP_NAVIGATION' => 'true',
64
+ ]);
65
+
66
+ self::assertTrue(Request::$isWire);
67
+ self::assertFalse(Request::$isRpc);
68
+ self::assertTrue(Request::$isNavigation);
69
+ }
70
+
71
+ public function testRpcHeaderOnAGetIsNotAnRpc(): void
72
+ {
73
+ $this->initRequest([
74
+ 'REQUEST_METHOD' => 'GET',
75
+ 'HTTP_X_PP_RPC' => 'true',
76
+ ]);
77
+
78
+ self::assertFalse(Request::$isRpc);
79
+ }
80
+
81
+ public function testPlainRequestCarriesNoWireFlags(): void
82
+ {
83
+ $this->initRequest(['REQUEST_METHOD' => 'GET']);
84
+
85
+ self::assertFalse(Request::$isWire);
86
+ self::assertFalse(Request::$isRpc);
87
+ self::assertFalse(Request::$isNavigation);
88
+ }
89
+
90
+ public function testLegacyWireHeaderIsNoLongerRecognized(): void
91
+ {
92
+ $this->initRequest([
93
+ 'REQUEST_METHOD' => 'POST',
94
+ 'CONTENT_TYPE' => 'application/json',
95
+ 'HTTP_PP_WIRE_REQUEST' => 'true',
96
+ ]);
97
+
98
+ self::assertFalse(Request::$isWire, 'complete migration: no legacy header support');
99
+ self::assertFalse(Request::$isRpc);
100
+ }
101
+ }
@@ -0,0 +1,69 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use Lib\Websocket\Socket;
8
+ use Lib\Websocket\SocketPool;
9
+ use PHPUnit\Framework\TestCase;
10
+ use Tests\Support\FakeConnection;
11
+ use Tests\Support\RequiresFeature;
12
+
13
+ /**
14
+ * A broadcast pool: one value to everyone, with gone connections pruned on
15
+ * the way.
16
+ */
17
+ final class SocketPoolTest extends TestCase
18
+ {
19
+ use RequiresFeature;
20
+
21
+ protected function setUp(): void
22
+ {
23
+ $this->requireFeature('websocket');
24
+ }
25
+
26
+ public function testBroadcastReachesEveryOpenSocket(): void
27
+ {
28
+ $pool = new SocketPool();
29
+ $connA = new FakeConnection();
30
+ $connB = new FakeConnection();
31
+
32
+ $pool->add(new Socket($connA, 'chat'));
33
+ $pool->add(new Socket($connB, 'chat'));
34
+
35
+ $pool->broadcast(['text' => 'hello room']);
36
+
37
+ self::assertSame([['text' => 'hello room']], $connA->sentFrames);
38
+ self::assertSame([['text' => 'hello room']], $connB->sentFrames);
39
+ }
40
+
41
+ public function testClosedSocketsArePrunedByBroadcast(): void
42
+ {
43
+ $pool = new SocketPool();
44
+ $connA = new FakeConnection();
45
+ $gone = new Socket(new FakeConnection(), 'chat');
46
+
47
+ $pool->add(new Socket($connA, 'chat'));
48
+ $pool->add($gone);
49
+ $gone->handleClose();
50
+
51
+ $pool->broadcast('first');
52
+
53
+ self::assertSame(1, $pool->count(), 'the closed connection was forgotten');
54
+ self::assertSame(['first'], $connA->sentFrames);
55
+ }
56
+
57
+ public function testDiscardRemovesOnlyThatSocket(): void
58
+ {
59
+ $pool = new SocketPool();
60
+ $keep = new Socket(new FakeConnection(), 'chat');
61
+ $drop = new Socket(new FakeConnection(), 'chat');
62
+
63
+ $pool->add($keep);
64
+ $pool->add($drop);
65
+ $pool->discard($drop);
66
+
67
+ self::assertSame(1, $pool->count());
68
+ }
69
+ }
@@ -0,0 +1,77 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use InvalidArgumentException;
8
+ use Lib\Websocket\SocketRegistry;
9
+ use PHPUnit\Framework\TestCase;
10
+ use ReflectionClass;
11
+ use Tests\Support\RequiresFeature;
12
+
13
+ /**
14
+ * The named-socket registry: names are the whole address, so they are unique
15
+ * application-wide and a duplicate is refused at registration time.
16
+ */
17
+ final class SocketRegistryTest extends TestCase
18
+ {
19
+ use RequiresFeature;
20
+
21
+ protected function setUp(): void
22
+ {
23
+ $this->requireFeature('websocket');
24
+ self::resetRegistry();
25
+ }
26
+
27
+ protected function tearDown(): void
28
+ {
29
+ self::resetRegistry();
30
+ }
31
+
32
+ public static function resetRegistry(): void
33
+ {
34
+ // tearDown() still runs after a feature skip, and in an app without
35
+ // the websocket scaffold the class does not exist — nothing to reset.
36
+ if (!class_exists(SocketRegistry::class)) {
37
+ return;
38
+ }
39
+
40
+ $reflection = new ReflectionClass(SocketRegistry::class);
41
+ $reflection->getProperty('sockets')->setValue(null, []);
42
+ }
43
+
44
+ public function testRegisterAndResolveByName(): void
45
+ {
46
+ $handler = static function (): void {};
47
+
48
+ SocketRegistry::register('chat', $handler, requireAuth: true, allowedRoles: ['admin']);
49
+
50
+ $entry = SocketRegistry::get('chat');
51
+ self::assertNotNull($entry);
52
+ self::assertSame('chat', $entry['name']);
53
+ self::assertSame($handler, $entry['handler']);
54
+ self::assertTrue($entry['requireAuth']);
55
+ self::assertSame(['admin'], $entry['allowedRoles']);
56
+ self::assertSame(['chat'], SocketRegistry::names());
57
+ }
58
+
59
+ public function testUnknownNameResolvesToNull(): void
60
+ {
61
+ self::assertNull(SocketRegistry::get('nope'));
62
+ }
63
+
64
+ public function testDuplicateNameIsRefused(): void
65
+ {
66
+ SocketRegistry::register('chat', static function (): void {});
67
+
68
+ $this->expectException(InvalidArgumentException::class);
69
+ SocketRegistry::register('chat', static function (): void {});
70
+ }
71
+
72
+ public function testEmptyNameIsRefused(): void
73
+ {
74
+ $this->expectException(InvalidArgumentException::class);
75
+ SocketRegistry::register('', static function (): void {});
76
+ }
77
+ }
@@ -0,0 +1,124 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use InvalidArgumentException;
8
+ use Lib\Websocket\Socket;
9
+ use PHPUnit\Framework\TestCase;
10
+ use Tests\Support\FakeConnection;
11
+ use Tests\Support\RequiresFeature;
12
+
13
+ /**
14
+ * One open connection, as a socket handler holds it: JSON frames out, the
15
+ * reserved `{"error": ...}` shape refused, and close semantics.
16
+ */
17
+ final class SocketTest extends TestCase
18
+ {
19
+ use RequiresFeature;
20
+
21
+ protected function setUp(): void
22
+ {
23
+ $this->requireFeature('websocket');
24
+ }
25
+
26
+ public function testSendEncodesOneJsonValuePerFrame(): void
27
+ {
28
+ $conn = new FakeConnection();
29
+ $socket = new Socket($conn, 'echo');
30
+
31
+ self::assertTrue($socket->send(['text' => 'hello']));
32
+ self::assertTrue($socket->send('plain'));
33
+ self::assertTrue($socket->send(42));
34
+
35
+ self::assertSame([['text' => 'hello'], 'plain', 42], $conn->sentFrames);
36
+ }
37
+
38
+ public function testSendRefusesTheReservedErrorShape(): void
39
+ {
40
+ $socket = new Socket(new FakeConnection(), 'echo');
41
+
42
+ $this->expectException(InvalidArgumentException::class);
43
+ $socket->send(['error' => 'looks like a failure frame']);
44
+ }
45
+
46
+ public function testAPayloadThatMerelyContainsAnErrorKeyIsSendable(): void
47
+ {
48
+ $conn = new FakeConnection();
49
+ $socket = new Socket($conn, 'echo');
50
+
51
+ self::assertTrue($socket->send(['error' => 'mine', 'other' => 1]));
52
+ self::assertSame(['error' => 'mine', 'other' => 1], $conn->lastFrame());
53
+ }
54
+
55
+ public function testSendAfterCloseReturnsFalse(): void
56
+ {
57
+ $conn = new FakeConnection();
58
+ $socket = new Socket($conn, 'echo');
59
+
60
+ $socket->handleClose();
61
+
62
+ self::assertFalse($socket->send('late'));
63
+ self::assertSame([], $conn->sentFrames);
64
+ }
65
+
66
+ public function testMessageListenerReceivesDecodedValues(): void
67
+ {
68
+ $socket = new Socket(new FakeConnection(), 'echo');
69
+
70
+ $seen = [];
71
+ $socket->onMessage(function (mixed $value) use (&$seen): void {
72
+ $seen[] = $value;
73
+ });
74
+
75
+ $socket->handleMessage(['text' => 'hi']);
76
+ $socket->handleMessage('plain');
77
+
78
+ self::assertSame([['text' => 'hi'], 'plain'], $seen);
79
+ }
80
+
81
+ public function testCloseListenerFiresExactlyOnce(): void
82
+ {
83
+ $socket = new Socket(new FakeConnection(), 'echo');
84
+
85
+ $closes = 0;
86
+ $socket->onClose(function () use (&$closes): void {
87
+ $closes++;
88
+ });
89
+
90
+ $socket->handleClose();
91
+ $socket->handleClose();
92
+
93
+ self::assertSame(1, $closes);
94
+ self::assertFalse($socket->isOpen());
95
+ }
96
+
97
+ public function testErrorSendsTheReservedFrameThenCloses(): void
98
+ {
99
+ $conn = new FakeConnection();
100
+ $socket = new Socket($conn, 'echo');
101
+
102
+ $socket->error('no such room');
103
+
104
+ self::assertSame(['error' => 'no such room'], $conn->lastFrame());
105
+ self::assertSame([1008], $conn->closeCodes);
106
+ }
107
+
108
+ public function testCloseUsesNormalClosure(): void
109
+ {
110
+ $conn = new FakeConnection();
111
+ $socket = new Socket($conn, 'echo');
112
+
113
+ $socket->close();
114
+
115
+ self::assertSame([1000], $conn->closeCodes);
116
+ }
117
+
118
+ public function testPayloadIsHandedToTheHandler(): void
119
+ {
120
+ $socket = new Socket(new FakeConnection(), 'echo', ['role' => 'admin']);
121
+
122
+ self::assertSame(['role' => 'admin'], $socket->payload());
123
+ }
124
+ }
@@ -0,0 +1,40 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use Lib\Websocket\SocketRegistry;
8
+ use PHPUnit\Framework\TestCase;
9
+ use Tests\Support\RequiresFeature;
10
+
11
+ /**
12
+ * The app's real registration file loads cleanly and registers every socket
13
+ * the frontend connects to. A socket the browser names but the server never
14
+ * registered is a refused connection in production — catch it here instead.
15
+ */
16
+ final class SocketsRegistrationTest extends TestCase
17
+ {
18
+ use RequiresFeature;
19
+
20
+ protected function setUp(): void
21
+ {
22
+ $this->requireFeature('websocket');
23
+ SocketRegistryTest::resetRegistry();
24
+ }
25
+
26
+ protected function tearDown(): void
27
+ {
28
+ SocketRegistryTest::resetRegistry();
29
+ }
30
+
31
+ public function testTheRegistrationFileLoadsAndNamesItsSockets(): void
32
+ {
33
+ require DOCUMENT_PATH . '/src/Lib/Websocket/sockets.php';
34
+
35
+ $names = SocketRegistry::names();
36
+
37
+ self::assertContains('echo', $names);
38
+ self::assertContains('chat', $names);
39
+ }
40
+ }
@@ -0,0 +1,62 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests\Support;
6
+
7
+ use Ratchet\ConnectionInterface;
8
+ use GuzzleHttp\Psr7\Request as Psr7Request;
9
+
10
+ /**
11
+ * A Ratchet connection stand-in that records what the server sends.
12
+ *
13
+ * The named-socket wire is frames and closes, so a test only needs to see
14
+ * both: `$sentFrames` collects every `send(...)` payload (decoded from JSON
15
+ * when possible) and `$closeCodes` collects every `close(...)` code. The
16
+ * `httpRequest` property mirrors how Ratchet exposes the handshake request.
17
+ */
18
+ final class FakeConnection implements ConnectionInterface
19
+ {
20
+ /** @var list<mixed> Decoded JSON frames (raw string when not JSON). */
21
+ public array $sentFrames = [];
22
+
23
+ /** @var list<string> Raw frames exactly as sent. */
24
+ public array $rawFrames = [];
25
+
26
+ /** @var list<int> */
27
+ public array $closeCodes = [];
28
+
29
+ public bool $isClosed = false;
30
+
31
+ public ?Psr7Request $httpRequest = null;
32
+
33
+ /**
34
+ * @param array<string, string> $headers Handshake headers (e.g. Origin, Cookie).
35
+ */
36
+ public function __construct(string $uri = '/__pulsepoint/ws', array $headers = [])
37
+ {
38
+ $this->httpRequest = new Psr7Request('GET', $uri, $headers);
39
+ }
40
+
41
+ public function send($data): ConnectionInterface
42
+ {
43
+ $this->rawFrames[] = (string) $data;
44
+
45
+ $decoded = json_decode((string) $data, true);
46
+ $this->sentFrames[] = json_last_error() === JSON_ERROR_NONE ? $decoded : (string) $data;
47
+
48
+ return $this;
49
+ }
50
+
51
+ public function close($code = 1000): void
52
+ {
53
+ $this->closeCodes[] = (int) $code;
54
+ $this->isClosed = true;
55
+ }
56
+
57
+ /** The last frame the server sent, decoded. */
58
+ public function lastFrame(): mixed
59
+ {
60
+ return $this->sentFrames === [] ? null : end($this->sentFrames);
61
+ }
62
+ }
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests\Support;
6
+
7
+ /**
8
+ * Feature flags for the test suite, read from `prisma-php.json`.
9
+ *
10
+ * `prisma-php.json` is the single source of truth for which optional Prisma
11
+ * PHP features are enabled in the current app (`websocket`, `mcp`,
12
+ * `swaggerDocs`, `prisma`, `tailwindcss`, `typescript`, `backendOnly`).
13
+ * Tests that exercise an optional feature's scaffold must guard on the flag —
14
+ * when the feature is off, its files do not exist and the test must skip,
15
+ * not fatal.
16
+ */
17
+ final class Features
18
+ {
19
+ /** @var array<string, mixed>|null */
20
+ private static ?array $config = null;
21
+
22
+ /** @return array<string, mixed> The parsed prisma-php.json. */
23
+ public static function config(): array
24
+ {
25
+ if (self::$config === null) {
26
+ $raw = @file_get_contents(DOCUMENT_PATH . '/prisma-php.json');
27
+ $decoded = is_string($raw) ? json_decode($raw, true) : null;
28
+ self::$config = is_array($decoded) ? $decoded : [];
29
+ }
30
+
31
+ return self::$config;
32
+ }
33
+
34
+ public static function enabled(string $feature): bool
35
+ {
36
+ return (self::config()[$feature] ?? false) === true;
37
+ }
38
+ }
@@ -0,0 +1,26 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests\Support;
6
+
7
+ /**
8
+ * Skip guard for tests of optional Prisma PHP features.
9
+ *
10
+ * Call `$this->requireFeature('websocket')` as the FIRST line of `setUp()`,
11
+ * before touching any class from the feature's scaffold — a disabled
12
+ * feature's files are not generated, so reaching them would fatal instead
13
+ * of failing cleanly. A skipped test reports why and how to enable it.
14
+ */
15
+ trait RequiresFeature
16
+ {
17
+ protected function requireFeature(string $feature): void
18
+ {
19
+ if (!Features::enabled($feature)) {
20
+ self::markTestSkipped(
21
+ "The `$feature` feature is disabled in prisma-php.json; "
22
+ . "enable it and run `npx pp update project -y` to use this suite."
23
+ );
24
+ }
25
+ }
26
+ }