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.
- package/README.md +23 -2
- package/dist/.github/copilot-instructions.md +80 -33
- package/dist/AGENTS.md +59 -25
- package/dist/bootstrap.php +207 -187
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/bs-config.ts +44 -1
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/src/Lib/Auth/Auth.php +12 -25
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
- package/dist/src/Lib/Websocket/Socket.php +170 -0
- package/dist/src/Lib/Websocket/SocketPool.php +50 -0
- package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
- package/dist/src/Lib/Websocket/sockets.php +50 -0
- package/dist/src/Lib/Websocket/websocket-server.php +10 -3
- package/dist/src/app/globals.css +3 -1
- package/dist/src/app/layout.php +1 -1
- package/dist/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/DeferComponentRootsTest.php +147 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +119 -0
- package/dist/tests/RpcWireContractTest.php +101 -0
- package/dist/tests/SocketPoolTest.php +69 -0
- package/dist/tests/SocketRegistryTest.php +77 -0
- package/dist/tests/SocketTest.php +124 -0
- package/dist/tests/SocketsRegistrationTest.php +40 -0
- package/dist/tests/Support/FakeConnection.php +62 -0
- package/dist/tests/Support/Features.php +38 -0
- package/dist/tests/Support/RequiresFeature.php +26 -0
- package/dist/tests/bootstrap.php +44 -0
- package/dist/ts/main.ts +5 -8
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- 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 `&` keeps the escape visible to the runtime.
|
|
101
|
+
$html = $this->document(
|
|
102
|
+
'<div pp-component="page_1"><code>{literal}</code>'
|
|
103
|
+
. '<script>const s = "{";</script></div>'
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
$deferred = TemplateCompiler::deferComponentRoots($html);
|
|
107
|
+
|
|
108
|
+
self::assertStringContainsString('<code>&#123;literal&#125;</code>', $deferred);
|
|
109
|
+
// Script content is raw text in the template parse — left untouched.
|
|
110
|
+
self::assertStringContainsString('const s = "{";', $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
|
+
}
|