@saucebase/laravel-playwright 2.3.0 → 2.3.1
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 +388 -245
- package/dist/index.d.ts +1 -1
- package/dist/laravel-playwright.js +1 -1
- package/dist/laravel-playwright.umd.cjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,99 +1,368 @@
|
|
|
1
|
-
# Laravel Playwright
|
|
1
|
+
# Laravel and Playwright E2E Testing
|
|
2
2
|
|
|
3
|
+
A **Laravel and Playwright library** for end-to-end (E2E) browser testing.
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
Use Laravel factories, Artisan commands, database queries, configuration and time travel from your [Playwright](https://playwright.dev/) tests.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
- Create models using factories
|
|
8
|
-
- Run database queries
|
|
9
|
-
- Run PHP functions
|
|
10
|
-
- Update Laravel config while a test is running (until the test ends and calls `tearDown`).
|
|
11
|
-
- Register a boot function to run on every subsequent Laravel request in the test — useful for swapping service bindings.
|
|
12
|
-
- Traveling to a specific time in the application during the test
|
|
7
|
+
This package has two parts: a Laravel package that exposes testing endpoints, and a TypeScript fixture that calls them. Run your tests against a dedicated server and database; the fixture cleans up each test's configuration, clock and boot functions automatically.
|
|
13
8
|
|
|
14
|
-
##
|
|
9
|
+
## Contents
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
- [Requirements](#requirements)
|
|
12
|
+
- [Quick start](#quick-start)
|
|
13
|
+
- [API reference](#api-reference)
|
|
14
|
+
- [Reusable fixtures and PHP helpers](#reusable-fixtures-and-php-helpers)
|
|
15
|
+
- [Database setup and parallel tests](#database-setup-and-parallel-tests)
|
|
16
|
+
- [Configuration, time and boot functions](#configuration-time-and-boot-functions)
|
|
17
|
+
- [Environment and server configuration](#environment-and-server-configuration)
|
|
18
|
+
- [Security and custom endpoints](#security-and-custom-endpoints)
|
|
19
|
+
- [CI](#ci)
|
|
20
|
+
- [Troubleshooting](#troubleshooting)
|
|
21
|
+
- [Laravel Boost](#laravel-boost)
|
|
22
|
+
- [Upgrading from v1](#upgrading-from-v1)
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- PHP 8.3+
|
|
27
|
+
- Laravel 12 or 13
|
|
28
|
+
- Node.js 22+
|
|
29
|
+
- Playwright ^1.58.2
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
Run these commands from your existing Laravel application's root.
|
|
34
|
+
|
|
35
|
+
### 1. Install both packages and a browser
|
|
17
36
|
|
|
18
37
|
```bash
|
|
19
38
|
composer require --dev saucebase/laravel-playwright
|
|
39
|
+
npm install --save-dev @playwright/test @saucebase/laravel-playwright
|
|
40
|
+
npx playwright install chromium
|
|
20
41
|
```
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
Laravel discovers the provider automatically. To publish the configuration:
|
|
23
44
|
|
|
24
45
|
```bash
|
|
25
|
-
|
|
46
|
+
php artisan vendor:publish --tag=laravel-playwright-config
|
|
26
47
|
```
|
|
27
48
|
|
|
28
|
-
|
|
49
|
+
This creates `config/laravel-playwright.php`.
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
### 2. Create the test environment
|
|
31
52
|
|
|
32
53
|
```bash
|
|
33
|
-
php artisan
|
|
54
|
+
php artisan playwright:env
|
|
34
55
|
```
|
|
35
56
|
|
|
36
|
-
This creates
|
|
57
|
+
This creates `.env.e2e` from `.env`, with a separate SQLite database (requires PHP’s SQLite extension), `APP_ENV=e2e`, and `APP_URL=http://localhost:8100`.
|
|
37
58
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
> [!WARNING]
|
|
60
|
+
> `.env.e2e` contains your app's secrets. Add these entries to `.gitignore`:
|
|
61
|
+
>
|
|
62
|
+
> ```gitignore
|
|
63
|
+
> .env.e2e
|
|
64
|
+
> .env.e2e.backup
|
|
65
|
+
> /test-results/
|
|
66
|
+
> /playwright-report/
|
|
67
|
+
> ```
|
|
45
68
|
|
|
46
|
-
|
|
47
|
-
* The environments in which the testing endpoints are enabled.
|
|
48
|
-
* CAUTION: the endpoints run arbitrary PHP and SQL. Never enable them in
|
|
49
|
-
* production, nor in `local`, the environment your own dev server runs in.
|
|
50
|
-
*/
|
|
51
|
-
'environments' => ['e2e', 'testing'],
|
|
69
|
+
Use a dedicated test database, never your development or production database.
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
### 3. Configure Playwright
|
|
72
|
+
|
|
73
|
+
Create or update `playwright.config.ts` in the app root:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineConfig } from '@playwright/test';
|
|
77
|
+
import { laravelServer, type LaravelOptions } from '@saucebase/laravel-playwright';
|
|
78
|
+
|
|
79
|
+
export default defineConfig<LaravelOptions>({
|
|
80
|
+
testDir: './tests/e2e',
|
|
81
|
+
use: {
|
|
82
|
+
baseURL: 'http://localhost:8100',
|
|
83
|
+
},
|
|
84
|
+
webServer: laravelServer(),
|
|
85
|
+
});
|
|
59
86
|
```
|
|
60
87
|
|
|
61
|
-
|
|
88
|
+
`laravelServer()` migrates the E2E database, starts Laravel in the `e2e` environment, and waits for it to respond. Playwright manages the server's lifecycle. You do not need to start Herd, Docker or a separate Artisan server for this suite.
|
|
62
89
|
|
|
63
|
-
|
|
90
|
+
Keep `baseURL` equal to `APP_URL` in `.env.e2e`. The client automatically uses `${baseURL}/playwright` for its testing endpoints.
|
|
64
91
|
|
|
65
|
-
|
|
92
|
+
### 4. Write your first test
|
|
66
93
|
|
|
67
|
-
|
|
94
|
+
Create `tests/e2e/example.spec.ts`:
|
|
68
95
|
|
|
69
96
|
```ts
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
import { expect } from '@playwright/test';
|
|
98
|
+
import { test } from '@saucebase/laravel-playwright';
|
|
99
|
+
|
|
100
|
+
test('opens the app and creates a user', async ({ page, laravel }) => {
|
|
101
|
+
const user = await laravel.factory('User', {
|
|
102
|
+
email: `e2e-${crypto.randomUUID()}@example.com`,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const rows = await laravel.select(
|
|
106
|
+
'SELECT id FROM users WHERE email = :email',
|
|
107
|
+
{ email: user.email },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(rows).toEqual([{ id: user.id }]);
|
|
111
|
+
|
|
112
|
+
const response = await page.goto('/');
|
|
113
|
+
expect(response?.status()).toBe(200);
|
|
76
114
|
});
|
|
77
115
|
```
|
|
78
116
|
|
|
79
|
-
|
|
117
|
+
Import `test` from this package to get the `laravel` fixture; import `expect` from Playwright. Use your app's factory and page URL in the example.
|
|
118
|
+
|
|
119
|
+
### 5. Run it
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx playwright test
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
You should see one passing test. For interactive debugging, use `npx playwright test --ui`.
|
|
126
|
+
|
|
127
|
+
If your app requires compiled frontend assets, run `npm run build` before testing.
|
|
128
|
+
|
|
129
|
+
## API reference
|
|
130
|
+
|
|
131
|
+
All methods below are available on the `laravel` fixture and return promises.
|
|
132
|
+
|
|
133
|
+
| Method | Purpose | Result |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `artisan(command, parameters?)` | Run an Artisan command | `{ code, output }` |
|
|
136
|
+
| `factory(model, attributes?, count?)` | Create models using their factories | One object, or an array when `count` is provided |
|
|
137
|
+
| `select(sql, bindings?, options?)` | Read database rows | Array of objects |
|
|
138
|
+
| `query(sql, bindings?, options?)` | Execute a database statement | `{ success }` |
|
|
139
|
+
| `truncate(connections?)` | Truncate tables on the selected connections | Await completion |
|
|
140
|
+
| `callFunction<T>(callable, arguments?)` | Call PHP once and decode its JSON result | `T` |
|
|
141
|
+
| `config(key, value)` | Change configuration for this test | Await completion |
|
|
142
|
+
| `travel(datetime)` | Set Laravel's clock for this test | Await completion |
|
|
143
|
+
| `registerBootFunction(callable)` | Run PHP at boot on subsequent requests in this test | Await completion |
|
|
144
|
+
| `tearDown()` | Remove this test's config, clock and boot functions | Called automatically by the fixture |
|
|
145
|
+
|
|
146
|
+
### Artisan
|
|
147
|
+
|
|
148
|
+
Pass options in the command string:
|
|
80
149
|
|
|
81
150
|
```ts
|
|
82
|
-
|
|
151
|
+
const result = await laravel.artisan('db:seed --class=DatabaseSeeder');
|
|
152
|
+
expect(result.code).toBe(0);
|
|
153
|
+
```
|
|
83
154
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
155
|
+
Commands run from your application's root. Inspect `code` to check whether the command succeeded. Do not pass CLI tokens such as `['--class', 'DatabaseSeeder']` as the second argument: Laravel expects named arguments and options, not a token list.
|
|
156
|
+
|
|
157
|
+
### Factories
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const user = await laravel.factory('User', {
|
|
161
|
+
email: `e2e-${crypto.randomUUID()}@example.com`,
|
|
162
|
+
});
|
|
163
|
+
const users = await laravel.factory('User', {}, 3);
|
|
164
|
+
const otherUser = await laravel.factory(String.raw`App\Models\User`);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A short name resolves to `App\Models\<Name>`. For a module or package model, pass its full class name. Models must expose Laravel's `factory()` method. Use a PHP helper for factory states or relationships.
|
|
168
|
+
|
|
169
|
+
### Queries
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const users = await laravel.select(
|
|
173
|
+
'SELECT id, email FROM users WHERE email = :email',
|
|
174
|
+
{ email: user.email },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await laravel.query('UPDATE users SET name = ? WHERE id = ?', ['Updated', user.id]);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Both methods accept a third argument `{ connection: 'connection_name' }`. `query()` also accepts `{ unprepared: true }` for raw statements; it cannot be combined with bindings. Use bindings for values.
|
|
181
|
+
|
|
182
|
+
### PHP functions
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
const appName = await laravel.callFunction<string>('config', ['app.name']);
|
|
186
|
+
const credentials = await laravel.callFunction<{ email: string; password: string }>(
|
|
187
|
+
String.raw`Tests\Support\E2eUsers::create`,
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Pass positional arguments as an array, or named arguments as an object. The callable must exist and be autoloadable in the app. Results must be JSON-serializable; TypeScript's generic describes the expected shape, it does not validate the response.
|
|
192
|
+
|
|
193
|
+
## Reusable fixtures and PHP helpers
|
|
194
|
+
|
|
195
|
+
As a suite grows, give it one fixture entry point. Keep application-specific setup in PHP helpers instead of repeating SQL or model details in TypeScript.
|
|
196
|
+
|
|
197
|
+
For example, create `tests/Support/E2eUsers.php`. A standard Laravel app already autoloads the `Tests\` namespace for development:
|
|
198
|
+
|
|
199
|
+
```php
|
|
200
|
+
<?php
|
|
201
|
+
|
|
202
|
+
namespace Tests\Support;
|
|
203
|
+
|
|
204
|
+
use App\Models\User;
|
|
205
|
+
use Illuminate\Support\Facades\Hash;
|
|
206
|
+
use Illuminate\Support\Str;
|
|
207
|
+
|
|
208
|
+
class E2eUsers
|
|
209
|
+
{
|
|
210
|
+
/** @return array{email: string, password: string} */
|
|
211
|
+
public static function create(): array
|
|
212
|
+
{
|
|
213
|
+
$password = Str::random(24);
|
|
214
|
+
$user = User::factory()->create([
|
|
215
|
+
'email' => 'e2e-'.Str::uuid().'@example.com',
|
|
216
|
+
'password' => Hash::make($password),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
return ['email' => $user->email, 'password' => $password];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Create `tests/e2e/fixtures.ts`:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { test as base } from '@saucebase/laravel-playwright';
|
|
228
|
+
export { expect } from '@playwright/test';
|
|
229
|
+
|
|
230
|
+
type Credentials = { email: string; password: string };
|
|
231
|
+
|
|
232
|
+
export const test = base.extend<{ credentials: Credentials }>({
|
|
233
|
+
credentials: async ({ laravel }, use) => {
|
|
234
|
+
const credentials = await laravel.callFunction<Credentials>(
|
|
235
|
+
String.raw`Tests\Support\E2eUsers::create`,
|
|
236
|
+
);
|
|
237
|
+
await use(credentials);
|
|
88
238
|
},
|
|
89
239
|
});
|
|
90
240
|
```
|
|
91
241
|
|
|
92
|
-
|
|
242
|
+
Then import your fixture in specs:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import { test, expect } from './fixtures';
|
|
246
|
+
|
|
247
|
+
test('prepares an account', async ({ credentials, laravel }) => {
|
|
248
|
+
const users = await laravel.select(
|
|
249
|
+
'SELECT email FROM users WHERE email = :email',
|
|
250
|
+
{ email: credentials.email },
|
|
251
|
+
);
|
|
252
|
+
expect(users).toEqual([{ email: credentials.email }]);
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Each test gets a different user. Build an app-specific `loginAs` fixture on top once your app has an authentication flow; a plain Laravel installation has no login page.
|
|
257
|
+
|
|
258
|
+
You can also fail tests on uncaught browser exceptions with an automatic fixture:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { expect } from '@playwright/test';
|
|
262
|
+
import { test as base } from './fixtures';
|
|
263
|
+
|
|
264
|
+
export const test = base.extend<{ failOnPageError: void }>({
|
|
265
|
+
failOnPageError: [async ({ page }, use) => {
|
|
266
|
+
const errors: string[] = [];
|
|
267
|
+
page.on('pageerror', error => errors.push(error.message));
|
|
268
|
+
await use();
|
|
269
|
+
expect(errors, 'uncaught browser errors').toEqual([]);
|
|
270
|
+
}, { auto: true }],
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
These patterns are adapted from [Saucebase Core's fixtures](https://github.com/saucebase-dev/core/blob/main/stubs/app/tests/e2e/fixtures/index.ts). See [Saucebase](https://github.com/saucebase-dev/saucebase) for a larger application example. Roles, billing accounts and module helpers in that app are application features, not requirements of this package.
|
|
275
|
+
|
|
276
|
+
## Database setup and parallel tests
|
|
277
|
+
|
|
278
|
+
`laravelServer()` runs migrations; it does **not** reset your database on every run. Factory-created rows survive test teardown. Configuration, time and boot functions are isolated per test, but database rows are shared.
|
|
279
|
+
|
|
280
|
+
For a clean database at the start of each suite, create `tests/e2e/database.setup.ts`:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import { expect } from '@playwright/test';
|
|
284
|
+
import { test as setup } from '@saucebase/laravel-playwright';
|
|
285
|
+
|
|
286
|
+
setup('reset the E2E database', async ({ laravel }) => {
|
|
287
|
+
const result = await laravel.artisan('migrate:fresh --seed');
|
|
288
|
+
expect(result.code).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Add projects to your existing `defineConfig` call:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
projects: [
|
|
296
|
+
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
|
297
|
+
{ name: 'chromium', testMatch: /.*\.spec\.ts/, dependencies: ['setup'] },
|
|
298
|
+
],
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**This deletes all data in the E2E database.** Never run `migrate:fresh` or `truncate()` inside tests that execute alongside other tests. Reset once before the workers start.
|
|
302
|
+
|
|
303
|
+
Use unique emails and identifiers for mutable records. Shared fixed-email accounts should be seeded once in setup and only read by tests. Per-process Faker uniqueness does not guarantee uniqueness across separate HTTP requests.
|
|
304
|
+
|
|
305
|
+
`await laravel.truncate()` truncates the default connection; pass an array of connection names to select others.
|
|
306
|
+
|
|
307
|
+
## Configuration, time and boot functions
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { test } from '@saucebase/laravel-playwright';
|
|
311
|
+
import { expect } from '@playwright/test';
|
|
312
|
+
|
|
313
|
+
test('changes config and time', async ({ laravel }) => {
|
|
314
|
+
await laravel.config('app.timezone', 'Europe/Dublin');
|
|
315
|
+
await laravel.travel('2026-01-01 12:00:00');
|
|
316
|
+
expect(await laravel.callFunction<string>('config', ['app.timezone']))
|
|
317
|
+
.toBe('Europe/Dublin');
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Changes apply to subsequent requests carrying this test's ID, until the fixture calls `tearDown()`. Time travel changes Laravel's clock, not the browser's JavaScript clock.
|
|
322
|
+
|
|
323
|
+
### Boot functions
|
|
324
|
+
|
|
325
|
+
`callFunction()` runs once. `registerBootFunction()` registers a callable that runs at the start of subsequent Laravel requests in the test. Use it when browser requests need a service binding or fake installed on every request.
|
|
326
|
+
|
|
327
|
+
For example, create `tests/Support/E2eBoot.php`:
|
|
328
|
+
|
|
329
|
+
```php
|
|
330
|
+
<?php
|
|
93
331
|
|
|
94
|
-
|
|
332
|
+
namespace Tests\Support;
|
|
333
|
+
|
|
334
|
+
use Illuminate\Support\Facades\Mail;
|
|
335
|
+
|
|
336
|
+
class E2eBoot
|
|
337
|
+
{
|
|
338
|
+
public static function fakeMail(): void
|
|
339
|
+
{
|
|
340
|
+
Mail::fake();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Register it before navigating to the app:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
await laravel.registerBootFunction(String.raw`Tests\Support\E2eBoot::fakeMail`);
|
|
349
|
+
await page.goto('/');
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The fake is recreated for each request. Its recorded messages do not persist across requests; use this to prevent delivery, not to assert mail from an earlier request. The same pattern can bind your application's external-service interface to a fake implementation.
|
|
353
|
+
|
|
354
|
+
### Request isolation
|
|
355
|
+
|
|
356
|
+
The fixture creates a test ID and sends `X-Playwright-Test` on its Laravel calls and browser requests to the app. State is stored in `storage/framework/playwright/<test-id>.json` and removed automatically at teardown.
|
|
357
|
+
|
|
358
|
+
- Parallel tests do not see or delete each other's config, time or boot functions.
|
|
359
|
+
- The browser header matches the `baseURL` host and its subdomains; third-party requests are untouched. Override this with `use.laravelIsAppUrl: (url: URL) => boolean`.
|
|
360
|
+
- Custom routes intercepting app requests must call `route.fallback()`, not `route.continue()`, so the fixture can add the header.
|
|
361
|
+
- Contexts you create with `browser.newContext()` do not receive the header automatically.
|
|
362
|
+
- Requests without the ID, including external queue workers, do not see this state. A custom endpoint client must send the header itself.
|
|
363
|
+
|
|
364
|
+
## Environment and server configuration
|
|
95
365
|
|
|
96
|
-
**1. Create `.env.e2e`:**
|
|
97
366
|
|
|
98
367
|
```bash
|
|
99
368
|
php artisan playwright:env
|
|
@@ -136,7 +405,7 @@ If the file exists, the command asks whether to back it up and replace it, repla
|
|
|
136
405
|
|
|
137
406
|
Options: `php artisan playwright:env <environment> --url=http://localhost:9000`. Edit the file afterwards for anything else the suite needs. Keep `.env.e2e` out of git like `.env`, and in CI, run the command after creating `.env`.
|
|
138
407
|
|
|
139
|
-
|
|
408
|
+
### Server options
|
|
140
409
|
|
|
141
410
|
```ts
|
|
142
411
|
import { defineConfig } from '@playwright/test';
|
|
@@ -154,223 +423,97 @@ export default defineConfig<LaravelOptions>({
|
|
|
154
423
|
- `APP_URL` is missing or not `http:`. `artisan serve` has no TLS.
|
|
155
424
|
- The Playwright process already has another `APP_ENV`. The server inherits the process's environment, and real environment variables override any env file, so `import 'dotenv/config'` (which loads `.env`) would give the server your dev database. To read values from `.env.e2e` in the config, use `config({ path: '.env.e2e' })` from dotenv.
|
|
156
425
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
## 🔒 Security
|
|
160
|
-
|
|
161
|
-
The package supports an optional shared secret to prevent unauthorized access to the testing endpoints.
|
|
426
|
+
### Subdomains and multi-tenancy
|
|
427
|
+
Chromium resolves every `*.localhost` to your machine, so `http://acme.localhost:8100` reaches the same server with no DNS or hosts-file setup. To use a real wildcard host instead (Herd or Valet `*.app.test`), skip `laravelServer()` and point `baseURL` at that host, served with `APP_ENV=e2e`.
|
|
162
428
|
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
PLAYWRIGHT_SECRET=some-secret
|
|
166
|
-
```
|
|
429
|
+
## Security and custom endpoints
|
|
167
430
|
|
|
168
|
-
|
|
169
|
-
```ts
|
|
170
|
-
use: {
|
|
171
|
-
laravelSecret: process.env.PLAYWRIGHT_SECRET,
|
|
172
|
-
},
|
|
173
|
-
```
|
|
431
|
+
The endpoints can execute PHP, SQL and Artisan commands. They are enabled only in `e2e` and `testing` by default. Never enable them in production or on your regular `local` development server. Install the Composer package as a development dependency.
|
|
174
432
|
|
|
175
|
-
|
|
433
|
+
To customize the defaults:
|
|
176
434
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
In your Playwright tests, swap the `test` import from `@playwright/test` to `@saucebase/laravel-playwright`.
|
|
180
|
-
|
|
181
|
-
```diff
|
|
182
|
-
- import { test } from '@playwright/test';
|
|
183
|
-
+ import { test } from '@saucebase/laravel-playwright';
|
|
184
|
-
|
|
185
|
-
test('example', async ({ laravel }) => {
|
|
186
|
-
await laravel.artisan('migrate:fresh');
|
|
187
|
-
});
|
|
435
|
+
```bash
|
|
436
|
+
php artisan vendor:publish --tag=laravel-playwright-config
|
|
188
437
|
```
|
|
189
438
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
## 🚀 Basic Usage
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
import { test } from '@saucebase/laravel-playwright';
|
|
196
|
-
|
|
197
|
-
test('example', async ({ laravel }) => {
|
|
198
|
-
|
|
199
|
-
/** 🏃 RUN ARTISAN COMMANDS */
|
|
200
|
-
const output = await laravel.artisan('migrate:fresh');
|
|
201
|
-
/**
|
|
202
|
-
* output.code: number - The exit code of the command
|
|
203
|
-
* output.output: string - The output of the command
|
|
204
|
-
*/
|
|
205
|
-
/** with parameters */
|
|
206
|
-
await laravel.artisan('db:seed', ['--class', 'DatabaseSeeder']);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
/** 🗑️ TRUNCATE TABLES */
|
|
210
|
-
await laravel.truncate();
|
|
211
|
-
/** in specific DB connections */
|
|
212
|
-
await laravel.truncate(['connection1', 'connection2']);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
/** 🏭 CREATE MODELS FROM FACTORIES */
|
|
216
|
-
/**
|
|
217
|
-
* Create a App\Models\User model
|
|
218
|
-
* user will be an object of the model
|
|
219
|
-
*/
|
|
220
|
-
const user = await laravel.factory('User');
|
|
221
|
-
/** Create a App\Models\User model with attributes */
|
|
222
|
-
await laravel.factory('User', { name: 'John Doe' });
|
|
223
|
-
/**
|
|
224
|
-
* Create 5 App\Models\User models
|
|
225
|
-
* users will be an array of the models
|
|
226
|
-
*/
|
|
227
|
-
const users = await laravel.factory('User', {}, 5);
|
|
228
|
-
/** Create a CustomModel model */
|
|
229
|
-
await laravel.factory('CustomModel');
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
/** 💾 RUN A DATABASE QUERY */
|
|
233
|
-
await laravel.query('DELETE FROM users');
|
|
234
|
-
/** with bindings */
|
|
235
|
-
await laravel.query('DELETE FROM users WHERE id = ?', [1]);
|
|
236
|
-
/** on a specific connection */
|
|
237
|
-
await laravel.query('DELETE FROM users', [], { connection: 'connection1' });
|
|
238
|
-
/** unprepared statement */
|
|
239
|
-
await laravel.query(`
|
|
240
|
-
DROP SCHEMA public CASCADE;
|
|
241
|
-
CREATE SCHEMA public;
|
|
242
|
-
GRANT ALL ON SCHEMA public TO public;
|
|
243
|
-
`, [], { unprepared: true });
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
/** 🔍 RUN A SELECT QUERY */
|
|
247
|
-
/** returns an array of objects */
|
|
248
|
-
const blogs = await laravel.select('SELECT * FROM blogs');
|
|
249
|
-
/** with bindings */
|
|
250
|
-
await laravel.select('SELECT * FROM blogs WHERE id = ?', [1]);
|
|
251
|
-
/** on a specific connection */
|
|
252
|
-
await laravel.select('SELECT * FROM blogs', {}, { connection: 'connection1' });
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
/** ⚙️ RUN A PHP FUNCTION */
|
|
256
|
-
/**
|
|
257
|
-
* Output is JSON encoded in Laravel and decoded in Playwright
|
|
258
|
-
* The following examples call this function:
|
|
259
|
-
* function sayHello($name) { return "Hello, $name!"; }
|
|
260
|
-
*/
|
|
261
|
-
const funcOutput = await laravel.callFunction('sayHello');
|
|
262
|
-
/** with positional parameters */
|
|
263
|
-
await laravel.callFunction('sayHello', ['John']);
|
|
264
|
-
/** with named parameters */
|
|
265
|
-
await laravel.callFunction('sayHello', { name: 'John' });
|
|
266
|
-
/** static class method */
|
|
267
|
-
await laravel.callFunction("App\\MyAwesomeClass::method");
|
|
268
|
-
|
|
269
|
-
});
|
|
270
|
-
|
|
439
|
+
The published `config/laravel-playwright.php` contains:
|
|
271
440
|
|
|
441
|
+
```php
|
|
442
|
+
return [
|
|
443
|
+
'prefix' => env('PLAYWRIGHT_PREFIX', 'playwright'),
|
|
444
|
+
'environments' => ['e2e', 'testing'],
|
|
445
|
+
'secret' => env('PLAYWRIGHT_SECRET', null),
|
|
446
|
+
];
|
|
272
447
|
```
|
|
273
448
|
|
|
274
|
-
|
|
449
|
+
For a custom prefix, set `use.laravelBaseUrl` to the complete endpoint base URL, for example `http://localhost:8100/test-api`.
|
|
275
450
|
|
|
276
|
-
|
|
451
|
+
### Optional shared secret
|
|
277
452
|
|
|
278
|
-
|
|
453
|
+
Set `PLAYWRIGHT_SECRET` in `.env.e2e` and pass the same value as `use.laravelSecret`. The client sends `X-Playwright-Secret`; missing or incorrect secrets receive HTTP 401. Without a configured secret, no secret check is performed.
|
|
279
454
|
|
|
280
|
-
|
|
281
|
-
- The header is added with `context.route()`. If you add your own route for app URLs, call `route.fallback()`, not `route.continue()`, so the header is still added.
|
|
282
|
-
- A context you create yourself (`browser.newContext()`) doesn't get the header.
|
|
455
|
+
Node does not automatically load Laravel's env file. You can read the value without loading the entire file into the process environment:
|
|
283
456
|
|
|
284
457
|
```ts
|
|
285
|
-
import {
|
|
458
|
+
import { readFileSync } from 'node:fs';
|
|
459
|
+
import { parseEnv } from 'node:util';
|
|
286
460
|
|
|
287
|
-
|
|
461
|
+
const e2e = parseEnv(readFileSync('.env.e2e', 'utf8'));
|
|
288
462
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
await laravel.config('app.timezone', 'America/Sao_Paulo');
|
|
295
|
-
|
|
296
|
-
/** ⏰ TRAVEL TO A TIME */
|
|
297
|
-
/** similar to Laravel's `travelTo` method */
|
|
298
|
-
await laravel.travel('2022-01-01 12:00:00');
|
|
299
|
-
|
|
300
|
-
});
|
|
463
|
+
// Inside defineConfig:
|
|
464
|
+
use: {
|
|
465
|
+
baseURL: e2e.APP_URL,
|
|
466
|
+
laravelSecret: e2e.PLAYWRIGHT_SECRET,
|
|
467
|
+
},
|
|
301
468
|
```
|
|
302
469
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
Boot functions let you run PHP code **at the start of every subsequent Laravel request** during a test. This is the right tool when you need a service binding or side effect to be in place for browser-driven requests — not just during test setup.
|
|
306
|
-
|
|
307
|
-
A common use case is swapping a real external service (payment gateway, mailer, SMS provider) with a fake for the duration of a test.
|
|
308
|
-
|
|
309
|
-
**1. Create a helper class in your app** (e.g. `app/E2EHelpers.php`):
|
|
310
|
-
|
|
311
|
-
```php
|
|
312
|
-
<?php
|
|
470
|
+
Keep the test server bound to loopback when possible. A secret does not make these endpoints appropriate for a production deployment.
|
|
313
471
|
|
|
314
|
-
|
|
472
|
+
## CI
|
|
315
473
|
|
|
316
|
-
|
|
317
|
-
use App\Services\FakePaymentGateway;
|
|
318
|
-
use App\Services\Mailer;
|
|
319
|
-
use App\Services\NullMailer;
|
|
474
|
+
After checking out the app and installing a compatible PHP and Node version, a typical Linux CI job runs:
|
|
320
475
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
}
|
|
476
|
+
```bash
|
|
477
|
+
composer install --no-interaction --prefer-dist
|
|
478
|
+
npm ci
|
|
479
|
+
cp .env.example .env
|
|
480
|
+
php artisan key:generate --no-interaction
|
|
481
|
+
php artisan playwright:env --no-interaction
|
|
482
|
+
npm run build
|
|
483
|
+
npx playwright install --with-deps chromium
|
|
484
|
+
CI=true npx playwright test
|
|
332
485
|
```
|
|
333
486
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
```ts
|
|
337
|
-
import { test } from '@saucebase/laravel-playwright';
|
|
338
|
-
|
|
339
|
-
test('checkout with fake payment gateway', async ({ laravel, page }) => {
|
|
340
|
-
|
|
341
|
-
// Register once — runs at boot on every subsequent request this test makes
|
|
342
|
-
await laravel.registerBootFunction('App\\E2EHelpers::useFakeServices');
|
|
343
|
-
|
|
344
|
-
// From here, every page load and API call the browser makes will use the fakes
|
|
345
|
-
const user = await laravel.factory('User');
|
|
346
|
-
await laravel.artisan('db:seed', ['--class', 'ProductSeeder']);
|
|
347
|
-
|
|
348
|
-
await page.goto('/checkout');
|
|
349
|
-
await page.fill('[name="card_number"]', '4242424242424242');
|
|
350
|
-
await page.click('button[type="submit"]');
|
|
351
|
-
|
|
352
|
-
await expect(page.locator('.order-confirmation')).toBeVisible();
|
|
353
|
-
|
|
354
|
-
});
|
|
355
|
-
```
|
|
487
|
+
Keep development dependencies installed. Set any application-specific E2E overrides before generating `.env.e2e`. The example assumes the standard Laravel `.env.example` and SQLite; apps requiring external services need their own CI configuration. `CI=true` prevents `laravelServer()` from reusing an existing server.
|
|
356
488
|
|
|
357
|
-
|
|
358
|
-
> `callFunction` runs once and returns. `registerBootFunction` runs at the start of **every** request the browser makes during the test, so service bindings registered in the boot phase are in place for the full request lifecycle — including middleware, controllers, and event listeners.
|
|
489
|
+
## Troubleshooting
|
|
359
490
|
|
|
360
|
-
|
|
491
|
+
| Symptom | Check |
|
|
492
|
+
| --- | --- |
|
|
493
|
+
| Browser executable does not exist | Run `npx playwright install chromium` after installing or upgrading Playwright. |
|
|
494
|
+
| 404 from `/playwright/*` | Use the E2E server, not your regular local server. Check the configured prefix and environment. |
|
|
495
|
+
| 401 from a testing endpoint | Pass the same secret to Laravel and `use.laravelSecret`. |
|
|
496
|
+
| Server startup times out | Check `storage/logs/laravel.log`, missing assets, and whether `APP_URL` responds successfully. |
|
|
497
|
+
| Port already in use or requests reach the wrong app | Choose another port with `playwright:env --url=...` and update `baseURL`. Outside CI, an existing responding server is reused. |
|
|
498
|
+
| `APP_ENV` conflict | Do not load `.env` into Node; use `.env.e2e` or leave the process environment alone. |
|
|
499
|
+
| SQLite `database is locked` | Keep the generated DB_URL lock settings and use PHP 8.4+ for immediate transactions. Avoid concurrent suite-wide resets. |
|
|
500
|
+
| Tests pass alone but fail together | Look for shared mutable rows, fixed unique values, or resets running during other tests. |
|
|
501
|
+
| Config or time does not reach browser requests | Check custom route handlers, new browser contexts and the app-host match. |
|
|
502
|
+
| Artisan reports an unknown numbered argument | Use a command string, not a CLI token array as the second argument. |
|
|
361
503
|
|
|
362
|
-
##
|
|
504
|
+
## Laravel Boost
|
|
363
505
|
|
|
364
|
-
The package ships a
|
|
506
|
+
The package ships a Laravel Boost guideline and a `laravel-playwright-development` skill covering environments, per-test state, subdomains and common failures. Select `saucebase/laravel-playwright` during `php artisan boost:install`, or add it to `packages` in `boost.json` and run `php artisan boost:update`.
|
|
365
507
|
|
|
366
|
-
##
|
|
508
|
+
## Upgrading from v1
|
|
367
509
|
|
|
368
|
-
-
|
|
369
|
-
-
|
|
370
|
-
-
|
|
371
|
-
-
|
|
372
|
-
-
|
|
510
|
+
- The endpoints are disabled in `local`. Use the dedicated E2E environment. If you published config previously, change `environments` to `['e2e', 'testing']`.
|
|
511
|
+
- Dynamic configuration, time travel and boot functions are per test. State moved from `storage/laravel-playwright-config.json` to `storage/framework/playwright/<test-id>.json`.
|
|
512
|
+
- Custom clients must send `X-Playwright-Test`. State-changing endpoints return HTTP 400 without it; the supplied fixture handles it automatically.
|
|
513
|
+
- `DynamicConfig::set()`, `get()`, `getAll()`, `load()` and `delete()` take the test ID as their first argument.
|
|
514
|
+
- Artisan commands run from the application root.
|
|
515
|
+
- Laravel 12 or 13 is required.
|
|
373
516
|
|
|
374
|
-
##
|
|
517
|
+
## Credits
|
|
375
518
|
|
|
376
|
-
|
|
519
|
+
Inspired by [hyvor/laravel-playwright](https://github.com/hyvor/laravel-playwright).
|
package/dist/index.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare class Laravel {
|
|
|
31
31
|
readonly testId: string;
|
|
32
32
|
constructor(baseUrl: string, request: APIRequestContext, secret?: string | undefined, testId?: string);
|
|
33
33
|
call<T = unknown>(endpoint: string, data?: object): Promise<T>;
|
|
34
|
-
artisan(command: string, parameters?: string[]): Promise<{
|
|
34
|
+
artisan(command: string, parameters?: Record<string, string | number | boolean | (string | number)[]>): Promise<{
|
|
35
35
|
code: number;
|
|
36
36
|
output: string;
|
|
37
37
|
}>;
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
Failed to call Laravel ${e}.
|
|
3
3
|
Status: ${s.status()}
|
|
4
4
|
Response: ${await s.text()}
|
|
5
|
-
`);return await s.json()}async artisan(e,t=
|
|
5
|
+
`);return await s.json()}async artisan(e,t={}){return await this.call("/artisan",{command:e,parameters:t})}async truncate(e=[]){return await this.call("/truncate",{connections:e})}async factory(e,t={},a){return await this.call("/factory",{model:e,count:a,attrs:t})}async query(e,t=[],a={}){const{connection:r=null,unprepared:s=!1}=a;if(s&&t.length>0)throw new Error("Cannot use unprepared with bindings");return await this.call("/query",{query:e,bindings:t,connection:r,unprepared:s})}async select(e,t={},a={}){const{connection:r=null}=a;return await this.call("/select",{query:e,bindings:t,connection:r})}async callFunction(e,t=[]){return await this.call("/function",{function:e,args:t})}async config(e,t){return await this.call("/dynamicConfig",{key:e,value:t})}async travel(e){return await this.call("/travel",{to:e})}async registerBootFunction(e){return await this.call("/registerBootFunction",{function:e})}async tearDown(){return await this.call("/tearDown")}}n.Laravel=w,n.TEST_HEADER=h,n.isAppUrl=v,n.laravelServer=y,n.test=P,Object.defineProperty(n,Symbol.toStringTag,{value:"Module"})}));
|
package/package.json
CHANGED