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,277 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use Lib\Auth\Auth;
8
+ use Lib\Websocket\ConnectionManager;
9
+ use Lib\Websocket\Socket;
10
+ use Lib\Websocket\SocketRegistry;
11
+ use PHPUnit\Framework\TestCase;
12
+ use Tests\Support\FakeConnection;
13
+ use Tests\Support\RequiresFeature;
14
+
15
+ /**
16
+ * The named-socket wire from the server's side: where a connection must go,
17
+ * what the first frame is, and which failures travel as the reserved
18
+ * `{"error": ...}` frame.
19
+ *
20
+ * Mirrors the client runtime's SocketClient tests: name in the `name` query
21
+ * parameter, arguments as the first JSON object frame, refusals as an error
22
+ * frame followed by a close.
23
+ */
24
+ final class ConnectionManagerTest extends TestCase
25
+ {
26
+ use RequiresFeature;
27
+
28
+ /** @var array<string, string|false> */
29
+ private array $envBackup = [];
30
+
31
+ protected function setUp(): void
32
+ {
33
+ $this->requireFeature('websocket');
34
+ SocketRegistryTest::resetRegistry();
35
+ }
36
+
37
+ protected function tearDown(): void
38
+ {
39
+ SocketRegistryTest::resetRegistry();
40
+
41
+ foreach ($this->envBackup as $name => $previous) {
42
+ if ($previous === false) {
43
+ putenv($name);
44
+ } else {
45
+ putenv("$name=$previous");
46
+ }
47
+ }
48
+ $this->envBackup = [];
49
+ }
50
+
51
+ private function setEnv(string $name, string $value): void
52
+ {
53
+ if (!array_key_exists($name, $this->envBackup)) {
54
+ $this->envBackup[$name] = getenv($name);
55
+ }
56
+ putenv("$name=$value");
57
+ }
58
+
59
+ /** A connection from a dev-browser origin, upgraded and args sent. */
60
+ private function openWithArgs(
61
+ ConnectionManager $manager,
62
+ FakeConnection $conn,
63
+ array $args = [],
64
+ ): void {
65
+ $manager->onOpen($conn);
66
+ $manager->onMessage($conn, json_encode($args === [] ? new \stdClass() : $args));
67
+ }
68
+
69
+ private function devConnection(string $name): FakeConnection
70
+ {
71
+ return new FakeConnection(
72
+ "/__pulsepoint/ws?name=$name",
73
+ ['Origin' => 'http://localhost:3000'],
74
+ );
75
+ }
76
+
77
+ public function testUnknownNameIsRefusedWithAReadableErrorFrame(): void
78
+ {
79
+ $manager = new ConnectionManager();
80
+ $conn = $this->devConnection('nope');
81
+
82
+ $manager->onOpen($conn);
83
+
84
+ $frame = $conn->lastFrame();
85
+ self::assertIsArray($frame);
86
+ self::assertArrayHasKey('error', $frame);
87
+ self::assertStringContainsString('No socket named `nope`', $frame['error']);
88
+ self::assertSame([1008], $conn->closeCodes);
89
+ }
90
+
91
+ public function testMissingNameIsRefused(): void
92
+ {
93
+ $manager = new ConnectionManager();
94
+ $conn = new FakeConnection('/__pulsepoint/ws', ['Origin' => 'http://localhost:3000']);
95
+
96
+ $manager->onOpen($conn);
97
+
98
+ self::assertStringContainsString('named no socket', $conn->lastFrame()['error']);
99
+ self::assertSame([1008], $conn->closeCodes);
100
+ }
101
+
102
+ public function testArgumentsArriveAsTheFirstFrameAndReachTheHandler(): void
103
+ {
104
+ $received = null;
105
+ SocketRegistry::register('echo', function (Socket $socket, array $args) use (&$received): void {
106
+ $received = $args;
107
+ $socket->onMessage(fn (mixed $value) => $socket->send("you: $value"));
108
+ });
109
+
110
+ $manager = new ConnectionManager();
111
+ $conn = $this->devConnection('echo');
112
+
113
+ $this->openWithArgs($manager, $conn, ['room' => 'lobby']);
114
+ self::assertSame(['room' => 'lobby'], $received);
115
+
116
+ $manager->onMessage($conn, '"hello"');
117
+ self::assertSame('you: hello', $conn->lastFrame());
118
+ self::assertSame([], $conn->closeCodes, 'the conversation stays open');
119
+ }
120
+
121
+ public function testEmptyArgumentsObjectIsAccepted(): void
122
+ {
123
+ $received = null;
124
+ SocketRegistry::register('feed', function (Socket $socket, array $args) use (&$received): void {
125
+ $received = $args;
126
+ });
127
+
128
+ $manager = new ConnectionManager();
129
+ $conn = $this->devConnection('feed');
130
+
131
+ $this->openWithArgs($manager, $conn);
132
+
133
+ self::assertSame([], $received);
134
+ self::assertSame([], $conn->closeCodes);
135
+ }
136
+
137
+ public function testANonObjectFirstFrameIsRefused(): void
138
+ {
139
+ SocketRegistry::register('echo', static function (): void {});
140
+
141
+ $manager = new ConnectionManager();
142
+ $conn = $this->devConnection('echo');
143
+
144
+ $manager->onOpen($conn);
145
+ $manager->onMessage($conn, '[1, 2]');
146
+
147
+ self::assertStringContainsString('not a JSON object', $conn->lastFrame()['error']);
148
+ self::assertSame([1008], $conn->closeCodes);
149
+ }
150
+
151
+ public function testAnUnreadableFrameTravelsBackAsAnErrorFrame(): void
152
+ {
153
+ SocketRegistry::register('echo', function (Socket $socket): void {
154
+ $socket->onMessage(static function (): void {});
155
+ });
156
+
157
+ $manager = new ConnectionManager();
158
+ $conn = $this->devConnection('echo');
159
+
160
+ $this->openWithArgs($manager, $conn);
161
+ $manager->onMessage($conn, '{not json');
162
+
163
+ self::assertStringContainsString('could not read a frame', $conn->lastFrame()['error']);
164
+ }
165
+
166
+ public function testAnOversizedFrameClosesWith1009(): void
167
+ {
168
+ SocketRegistry::register('echo', static function (): void {});
169
+
170
+ $manager = new ConnectionManager();
171
+ $conn = $this->devConnection('echo');
172
+
173
+ $this->openWithArgs($manager, $conn);
174
+ $manager->onMessage($conn, json_encode(str_repeat('x', 5000)));
175
+
176
+ self::assertContains(1009, $conn->closeCodes);
177
+ }
178
+
179
+ public function testProductionRefusesAnUnknownOriginBeforeAnyFrame(): void
180
+ {
181
+ SocketRegistry::register('echo', static function (): void {});
182
+ $this->setEnv('APP_ENV', 'production');
183
+
184
+ $manager = new ConnectionManager();
185
+ $conn = new FakeConnection(
186
+ '/__pulsepoint/ws?name=echo',
187
+ ['Origin' => 'https://evil.example'],
188
+ );
189
+
190
+ $manager->onOpen($conn);
191
+
192
+ self::assertSame([], $conn->sentFrames, 'refused silently, before the wire speaks');
193
+ self::assertSame([1008], $conn->closeCodes);
194
+ }
195
+
196
+ public function testProductionAcceptsAnOriginNamedInTheAllowlist(): void
197
+ {
198
+ SocketRegistry::register('echo', static function (): void {});
199
+ $this->setEnv('APP_ENV', 'production');
200
+ $this->setEnv('WEBSOCKET_ALLOWED_ORIGINS', 'https://app.example');
201
+
202
+ $manager = new ConnectionManager();
203
+ $conn = new FakeConnection(
204
+ '/__pulsepoint/ws?name=echo',
205
+ ['Origin' => 'https://app.example'],
206
+ );
207
+
208
+ $manager->onOpen($conn);
209
+
210
+ self::assertSame([], $conn->closeCodes);
211
+ }
212
+
213
+ public function testRequireAuthRefusesAGuestConnection(): void
214
+ {
215
+ SocketRegistry::register('private', static function (): void {}, requireAuth: true);
216
+
217
+ $manager = new ConnectionManager();
218
+ $conn = $this->devConnection('private');
219
+
220
+ $manager->onOpen($conn);
221
+
222
+ self::assertStringContainsString('signed-in session', $conn->lastFrame()['error']);
223
+ self::assertSame([1008], $conn->closeCodes);
224
+ }
225
+
226
+ public function testAValidAuthCookieReachesTheHandlerAsThePayload(): void
227
+ {
228
+ $seenPayload = false;
229
+ SocketRegistry::register('private', function (Socket $socket) use (&$seenPayload): void {
230
+ $seenPayload = $socket->payload();
231
+ }, requireAuth: true);
232
+
233
+ $jwt = Auth::getInstance()->signIn('admin');
234
+
235
+ $manager = new ConnectionManager();
236
+ $conn = new FakeConnection('/__pulsepoint/ws?name=private', [
237
+ 'Origin' => 'http://localhost:3000',
238
+ 'Cookie' => Auth::$cookieName . '=' . $jwt,
239
+ ]);
240
+
241
+ $this->openWithArgs($manager, $conn);
242
+
243
+ self::assertSame('admin', $seenPayload);
244
+ self::assertSame([], $conn->closeCodes);
245
+ }
246
+
247
+ public function testAHandlerExceptionTravelsAsTheErrorFrame(): void
248
+ {
249
+ SocketRegistry::register('broken', static function (): void {
250
+ throw new \RuntimeException('boom');
251
+ });
252
+
253
+ $manager = new ConnectionManager();
254
+ $conn = $this->devConnection('broken');
255
+
256
+ $this->openWithArgs($manager, $conn);
257
+
258
+ $frame = $conn->lastFrame();
259
+ self::assertArrayHasKey('error', $frame);
260
+ self::assertStringContainsString('boom', $frame['error'], 'development shows the message');
261
+ self::assertSame([1008], $conn->closeCodes);
262
+ }
263
+
264
+ public function testCloseReleasesTheConnectionSlot(): void
265
+ {
266
+ SocketRegistry::register('echo', static function (): void {});
267
+
268
+ $manager = new ConnectionManager();
269
+ $conn = $this->devConnection('echo');
270
+
271
+ $this->openWithArgs($manager, $conn);
272
+ self::assertSame(1, $manager->openConnectionCount());
273
+
274
+ $manager->onClose($conn);
275
+ self::assertSame(0, $manager->openConnectionCount());
276
+ }
277
+ }
@@ -0,0 +1,119 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use PP\Security\Csrf;
9
+
10
+ /**
11
+ * The CSRF half of the RPC wire: the `pp_csrf` cookie family and the
12
+ * double-submit validation of the `X-CSRF-Token` header.
13
+ */
14
+ final class CsrfTest extends TestCase
15
+ {
16
+ protected function setUp(): void
17
+ {
18
+ $this->clearCsrfCookies();
19
+ }
20
+
21
+ protected function tearDown(): void
22
+ {
23
+ $this->clearCsrfCookies();
24
+ }
25
+
26
+ private function clearCsrfCookies(): void
27
+ {
28
+ foreach (array_keys($_COOKIE) as $name) {
29
+ if (str_starts_with((string) $name, 'pp_csrf')) {
30
+ unset($_COOKIE[$name]);
31
+ }
32
+ }
33
+ }
34
+
35
+ public function testCookieFamilyUsesThePpCsrfNamesTheRuntimeReads(): void
36
+ {
37
+ $names = Csrf::cookieNames();
38
+
39
+ self::assertNotEmpty($names);
40
+ self::assertSame('pp_csrf', end($names), 'plain pp_csrf is always the fallback name');
41
+
42
+ foreach ($names as $name) {
43
+ self::assertStringStartsWith('pp_csrf', $name);
44
+ }
45
+ }
46
+
47
+ public function testEnsureCookieMintsASignedTokenForEveryCookieName(): void
48
+ {
49
+ Csrf::ensureCookie();
50
+
51
+ foreach (Csrf::cookieNames() as $name) {
52
+ self::assertArrayHasKey($name, $_COOKIE);
53
+ self::assertMatchesRegularExpression(
54
+ '/^[0-9a-f]{32}\.[0-9a-f]{64}$/',
55
+ $_COOKIE[$name],
56
+ 'token is nonce.hmac-sha256'
57
+ );
58
+ }
59
+
60
+ $values = array_map(static fn (string $name) => $_COOKIE[$name], Csrf::cookieNames());
61
+ self::assertCount(1, array_unique($values), 'every issued name carries the same token');
62
+ }
63
+
64
+ public function testEnsureCookieKeepsAnExistingValidToken(): void
65
+ {
66
+ Csrf::ensureCookie();
67
+ $first = $_COOKIE['pp_csrf'];
68
+
69
+ Csrf::ensureCookie();
70
+
71
+ self::assertSame($first, $_COOKIE['pp_csrf']);
72
+ }
73
+
74
+ public function testRotateReplacesTheToken(): void
75
+ {
76
+ Csrf::ensureCookie();
77
+ $first = $_COOKIE['pp_csrf'];
78
+
79
+ Csrf::rotate();
80
+
81
+ self::assertNotSame($first, $_COOKIE['pp_csrf']);
82
+ }
83
+
84
+ public function testValidTokenRoundTrip(): void
85
+ {
86
+ Csrf::ensureCookie();
87
+
88
+ self::assertNull(Csrf::validateHeaderToken($_COOKIE['pp_csrf']));
89
+ }
90
+
91
+ public function testMissingHeaderTokenIsRefused(): void
92
+ {
93
+ Csrf::ensureCookie();
94
+
95
+ self::assertSame('Missing CSRF token', Csrf::validateHeaderToken(''));
96
+ }
97
+
98
+ public function testHeaderThatMatchesNoCookieIsRefused(): void
99
+ {
100
+ Csrf::ensureCookie();
101
+
102
+ self::assertSame(
103
+ 'Invalid CSRF token',
104
+ Csrf::validateHeaderToken(str_repeat('a', 32) . '.' . str_repeat('b', 64))
105
+ );
106
+ }
107
+
108
+ public function testTamperedSignatureIsRefusedEvenWhenTheCookieMatches(): void
109
+ {
110
+ // Double-submit alone is not enough: the signature must verify, so a
111
+ // token an attacker planted (cookie tossing) without the secret fails.
112
+ $forged = str_repeat('a', 32) . '.' . str_repeat('b', 64);
113
+ foreach (Csrf::cookieNames() as $name) {
114
+ $_COOKIE[$name] = $forged;
115
+ }
116
+
117
+ self::assertSame('Invalid CSRF token', Csrf::validateHeaderToken($forged));
118
+ }
119
+ }
@@ -0,0 +1,147 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use PP\PHPX\TemplateCompiler;
9
+
10
+ /**
11
+ * The server-deferred component-root contract, as the app depends on it.
12
+ *
13
+ * The final document wraps every outermost `[pp-component]` root inside
14
+ * `<body>` in an inert `<template pp-component="...">`. The browser never
15
+ * executes scripts or parses `{...}` placeholders inside a template, so
16
+ * component scripts cannot run before the PulsePoint runtime module loads
17
+ * ("pp is not defined") and raw placeholders cannot trigger bogus requests
18
+ * or value coercion before hydration. `pp.mount()` materializes
19
+ * `template[pp-component]` back into live DOM before scanning for roots.
20
+ */
21
+ final class DeferComponentRootsTest extends TestCase
22
+ {
23
+ private function document(string $bodyContent): string
24
+ {
25
+ return "<html>\n<head><title>t</title></head>\n<body>\n"
26
+ . $bodyContent
27
+ . "\n</body>\n</html>";
28
+ }
29
+
30
+ public function testWrapsRouteRootInInertTemplate(): void
31
+ {
32
+ $html = $this->document(
33
+ '<div pp-component="page_abc123">'
34
+ . '<p>Count: {count}</p>'
35
+ . '<script>const [count, setCount] = pp.state(0);</script>'
36
+ . '</div>'
37
+ );
38
+
39
+ $deferred = TemplateCompiler::deferComponentRoots($html);
40
+
41
+ self::assertStringContainsString(
42
+ '<template pp-component="page_abc123"><div pp-component="page_abc123">',
43
+ $deferred
44
+ );
45
+ self::assertStringContainsString('</div></template>', $deferred);
46
+ // The component script rides along inside the inert template, unchanged.
47
+ self::assertStringContainsString(
48
+ '<script>const [count, setCount] = pp.state(0);</script>',
49
+ $deferred
50
+ );
51
+ }
52
+
53
+ public function testOnlyOutermostBoundaryIsWrapped(): void
54
+ {
55
+ $html = $this->document(
56
+ '<div pp-component="layout_1">'
57
+ . '<div pp-component="page_2"><p>{msg}</p></div>'
58
+ . '</div>'
59
+ );
60
+
61
+ $deferred = TemplateCompiler::deferComponentRoots($html);
62
+
63
+ self::assertSame(1, substr_count($deferred, '<template pp-component='));
64
+ self::assertStringContainsString('<template pp-component="layout_1">', $deferred);
65
+ // The nested boundary stays a plain element inside the inert content.
66
+ self::assertStringContainsString('<div pp-component="page_2">', $deferred);
67
+ }
68
+
69
+ public function testWrapsFooterComponentScript(): void
70
+ {
71
+ $html = $this->document(
72
+ '<div pp-component="page_1"><p>hi</p></div>'
73
+ . '<script pp-component="s9footer">pp.state(1);</script>'
74
+ );
75
+
76
+ $deferred = TemplateCompiler::deferComponentRoots($html);
77
+
78
+ self::assertStringContainsString(
79
+ '<template pp-component="s9footer"><script pp-component="s9footer">pp.state(1);</script></template>',
80
+ $deferred
81
+ );
82
+ }
83
+
84
+ public function testSiblingBoundariesAreEachWrapped(): void
85
+ {
86
+ $html = $this->document(
87
+ '<main><div pp-component="a"><span>x</span></div>'
88
+ . '<div pp-component="b"><span>y</span></div></main>'
89
+ );
90
+
91
+ $deferred = TemplateCompiler::deferComponentRoots($html);
92
+
93
+ self::assertStringContainsString('<template pp-component="a"><div pp-component="a">', $deferred);
94
+ self::assertStringContainsString('<template pp-component="b"><div pp-component="b">', $deferred);
95
+ }
96
+
97
+ public function testEscapedBraceEntitiesGainOneEncodingLayer(): void
98
+ {
99
+ // The browser's parse of the template content consumes one entity
100
+ // layer; the extra `&amp;` keeps the escape visible to the runtime.
101
+ $html = $this->document(
102
+ '<div pp-component="page_1"><code>&#123;literal&#125;</code>'
103
+ . '<script>const s = "&#123;";</script></div>'
104
+ );
105
+
106
+ $deferred = TemplateCompiler::deferComponentRoots($html);
107
+
108
+ self::assertStringContainsString('<code>&amp;#123;literal&amp;#125;</code>', $deferred);
109
+ // Script content is raw text in the template parse — left untouched.
110
+ self::assertStringContainsString('const s = "&#123;";', $deferred);
111
+ }
112
+
113
+ public function testHeadContentIsNeverWrapped(): void
114
+ {
115
+ $html = "<html>\n<head><meta name=\"x\" pp-component=\"nope\"></head>\n<body>\n"
116
+ . '<div pp-component="page_1"><p>hi</p></div>'
117
+ . "\n</body>\n</html>";
118
+
119
+ $deferred = TemplateCompiler::deferComponentRoots($html);
120
+
121
+ self::assertStringNotContainsString('<template pp-component="nope">', $deferred);
122
+ self::assertStringContainsString('<template pp-component="page_1">', $deferred);
123
+ }
124
+
125
+ public function testAlreadyDeferredTemplateIsLeftAlone(): void
126
+ {
127
+ $html = $this->document(
128
+ '<template pp-component="page_1"><div pp-component="page_1"><p>hi</p></div></template>'
129
+ );
130
+
131
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
132
+ }
133
+
134
+ public function testDocumentWithoutBoundariesIsUnchanged(): void
135
+ {
136
+ $html = $this->document('<div><p>plain</p></div>');
137
+
138
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
139
+ }
140
+
141
+ public function testUnbalancedBoundaryMarkupLeavesDocumentUntouched(): void
142
+ {
143
+ $html = $this->document('<div pp-component="page_1"><p>never closed');
144
+
145
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
146
+ }
147
+ }
@@ -0,0 +1,41 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Tests\Support\Features;
9
+
10
+ /**
11
+ * The suite's own feature awareness: flags come from `prisma-php.json`, the
12
+ * single source of truth for which optional scaffolds exist in this app.
13
+ */
14
+ final class FeaturesTest extends TestCase
15
+ {
16
+ public function testConfigIsReadFromPrismaPhpJson(): void
17
+ {
18
+ $raw = json_decode((string) file_get_contents(DOCUMENT_PATH . '/prisma-php.json'), true);
19
+
20
+ self::assertIsArray($raw);
21
+ self::assertSame($raw, Features::config());
22
+ }
23
+
24
+ public function testEnabledMirrorsTheBooleanFlags(): void
25
+ {
26
+ $config = Features::config();
27
+
28
+ foreach (['websocket', 'mcp', 'swaggerDocs', 'prisma', 'tailwindcss', 'typescript'] as $flag) {
29
+ self::assertSame(
30
+ ($config[$flag] ?? false) === true,
31
+ Features::enabled($flag),
32
+ "Features::enabled('$flag') mirrors prisma-php.json"
33
+ );
34
+ }
35
+ }
36
+
37
+ public function testAnUnknownFlagIsDisabled(): void
38
+ {
39
+ self::assertFalse(Features::enabled('not-a-real-feature'));
40
+ }
41
+ }