@saucebase/laravel-playwright 2.2.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 +398 -237
- 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,358 +1,519 @@
|
|
|
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)
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
21
34
|
|
|
22
|
-
|
|
35
|
+
### 1. Install both packages and a browser
|
|
23
36
|
|
|
24
37
|
```bash
|
|
25
|
-
|
|
38
|
+
composer require --dev saucebase/laravel-playwright
|
|
39
|
+
npm install --save-dev @playwright/test @saucebase/laravel-playwright
|
|
40
|
+
npx playwright install chromium
|
|
26
41
|
```
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Publish the config file:
|
|
43
|
+
Laravel discovers the provider automatically. To publish the configuration:
|
|
31
44
|
|
|
32
45
|
```bash
|
|
33
46
|
php artisan vendor:publish --tag=laravel-playwright-config
|
|
34
47
|
```
|
|
35
48
|
|
|
36
|
-
This creates `config/laravel-playwright.php
|
|
49
|
+
This creates `config/laravel-playwright.php`.
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
return [
|
|
40
|
-
/**
|
|
41
|
-
* The prefix for the testing endpoints used to interact with Playwright.
|
|
42
|
-
* Make sure to update `use.laravelBaseUrl` in playwright.config.ts if you change this.
|
|
43
|
-
*/
|
|
44
|
-
'prefix' => env('PLAYWRIGHT_PREFIX', 'playwright'),
|
|
51
|
+
### 2. Create the test environment
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
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'],
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Optional secret token to authenticate Playwright requests.
|
|
55
|
-
* Set PLAYWRIGHT_SECRET in your .env and laravelSecret in playwright.config.ts.
|
|
56
|
-
*/
|
|
57
|
-
'secret' => env('PLAYWRIGHT_SECRET', null),
|
|
58
|
-
];
|
|
53
|
+
```bash
|
|
54
|
+
php artisan playwright:env
|
|
59
55
|
```
|
|
60
56
|
|
|
61
|
-
|
|
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`.
|
|
58
|
+
|
|
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
|
+
> ```
|
|
62
68
|
|
|
63
|
-
|
|
69
|
+
Use a dedicated test database, never your development or production database.
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
### 3. Configure Playwright
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
Create or update `playwright.config.ts` in the app root:
|
|
68
74
|
|
|
69
75
|
```ts
|
|
70
|
-
|
|
71
|
-
|
|
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',
|
|
72
81
|
use: {
|
|
73
|
-
|
|
74
|
-
laravelSecret: process.env.PLAYWRIGHT_SECRET, // optional
|
|
82
|
+
baseURL: 'http://localhost:8100',
|
|
75
83
|
},
|
|
84
|
+
webServer: laravelServer(),
|
|
76
85
|
});
|
|
77
86
|
```
|
|
78
87
|
|
|
79
|
-
|
|
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.
|
|
89
|
+
|
|
90
|
+
Keep `baseURL` equal to `APP_URL` in `.env.e2e`. The client automatically uses `${baseURL}/playwright` for its testing endpoints.
|
|
91
|
+
|
|
92
|
+
### 4. Write your first test
|
|
93
|
+
|
|
94
|
+
Create `tests/e2e/example.spec.ts`:
|
|
80
95
|
|
|
81
96
|
```ts
|
|
82
|
-
import
|
|
97
|
+
import { expect } from '@playwright/test';
|
|
98
|
+
import { test } from '@saucebase/laravel-playwright';
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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);
|
|
89
114
|
});
|
|
90
115
|
```
|
|
91
116
|
|
|
92
|
-
|
|
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.
|
|
93
118
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
**1. Create `.env.e2e`:**
|
|
119
|
+
### 5. Run it
|
|
97
120
|
|
|
98
121
|
```bash
|
|
99
|
-
|
|
122
|
+
npx playwright test
|
|
100
123
|
```
|
|
101
124
|
|
|
102
|
-
|
|
125
|
+
You should see one passing test. For interactive debugging, use `npx playwright test --ui`.
|
|
103
126
|
|
|
104
|
-
|
|
105
|
-
APP_ENV=e2e
|
|
106
|
-
APP_URL=http://localhost:8100
|
|
107
|
-
DB_CONNECTION=sqlite
|
|
108
|
-
DB_URL="sqlite:///database/e2e.sqlite?transaction_mode=IMMEDIATE&busy_timeout=10000"
|
|
109
|
-
DB_DATABASE=database/e2e.sqlite
|
|
110
|
-
SESSION_DRIVER=file
|
|
111
|
-
CACHE_STORE=file
|
|
112
|
-
QUEUE_CONNECTION=sync
|
|
113
|
-
MAIL_MAILER=log
|
|
114
|
-
REDIS_PREFIX=<your prefix>e2e_
|
|
115
|
-
```
|
|
127
|
+
If your app requires compiled frontend assets, run `npm run build` before testing.
|
|
116
128
|
|
|
117
|
-
|
|
129
|
+
## API reference
|
|
130
|
+
|
|
131
|
+
All methods below are available on the `laravel` fixture and return promises.
|
|
118
132
|
|
|
119
|
-
|
|
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 |
|
|
120
145
|
|
|
121
|
-
|
|
146
|
+
### Artisan
|
|
147
|
+
|
|
148
|
+
Pass options in the command string:
|
|
122
149
|
|
|
123
150
|
```ts
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
const result = await laravel.artisan('db:seed --class=DatabaseSeeder');
|
|
152
|
+
expect(result.code).toBe(0);
|
|
153
|
+
```
|
|
126
154
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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`,
|
|
130
162
|
});
|
|
163
|
+
const users = await laravel.factory('User', {}, 3);
|
|
164
|
+
const otherUser = await laravel.factory(String.raw`App\Models\User`);
|
|
131
165
|
```
|
|
132
166
|
|
|
133
|
-
|
|
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.
|
|
134
168
|
|
|
135
|
-
|
|
136
|
-
- `APP_URL` is missing or not `http:`. `artisan serve` has no TLS.
|
|
137
|
-
- 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.
|
|
169
|
+
### Queries
|
|
138
170
|
|
|
139
|
-
|
|
171
|
+
```ts
|
|
172
|
+
const users = await laravel.select(
|
|
173
|
+
'SELECT id, email FROM users WHERE email = :email',
|
|
174
|
+
{ email: user.email },
|
|
175
|
+
);
|
|
140
176
|
|
|
141
|
-
|
|
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.
|
|
142
181
|
|
|
143
|
-
|
|
182
|
+
### PHP functions
|
|
144
183
|
|
|
145
|
-
|
|
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
|
+
);
|
|
146
189
|
```
|
|
147
|
-
|
|
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
|
+
}
|
|
148
222
|
```
|
|
149
223
|
|
|
150
|
-
|
|
224
|
+
Create `tests/e2e/fixtures.ts`:
|
|
225
|
+
|
|
151
226
|
```ts
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
155
240
|
```
|
|
156
241
|
|
|
157
|
-
|
|
242
|
+
Then import your fixture in specs:
|
|
158
243
|
|
|
159
|
-
|
|
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
|
+
```
|
|
160
255
|
|
|
161
|
-
|
|
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.
|
|
162
257
|
|
|
163
|
-
|
|
164
|
-
- import { test } from '@playwright/test';
|
|
165
|
-
+ import { test } from '@saucebase/laravel-playwright';
|
|
258
|
+
You can also fail tests on uncaught browser exceptions with an automatic fixture:
|
|
166
259
|
|
|
167
|
-
|
|
168
|
-
|
|
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 }],
|
|
169
271
|
});
|
|
170
272
|
```
|
|
171
273
|
|
|
172
|
-
|
|
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.
|
|
173
275
|
|
|
174
|
-
##
|
|
276
|
+
## Database setup and parallel tests
|
|
175
277
|
|
|
176
|
-
|
|
177
|
-
|
|
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`:
|
|
178
281
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const output = await laravel.artisan('migrate:fresh');
|
|
183
|
-
/**
|
|
184
|
-
* output.code: number - The exit code of the command
|
|
185
|
-
* output.output: string - The output of the command
|
|
186
|
-
*/
|
|
187
|
-
/** with parameters */
|
|
188
|
-
await laravel.artisan('db:seed', ['--class', 'DatabaseSeeder']);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
/** 🗑️ TRUNCATE TABLES */
|
|
192
|
-
await laravel.truncate();
|
|
193
|
-
/** in specific DB connections */
|
|
194
|
-
await laravel.truncate(['connection1', 'connection2']);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
/** 🏭 CREATE MODELS FROM FACTORIES */
|
|
198
|
-
/**
|
|
199
|
-
* Create a App\Models\User model
|
|
200
|
-
* user will be an object of the model
|
|
201
|
-
*/
|
|
202
|
-
const user = await laravel.factory('User');
|
|
203
|
-
/** Create a App\Models\User model with attributes */
|
|
204
|
-
await laravel.factory('User', { name: 'John Doe' });
|
|
205
|
-
/**
|
|
206
|
-
* Create 5 App\Models\User models
|
|
207
|
-
* users will be an array of the models
|
|
208
|
-
*/
|
|
209
|
-
const users = await laravel.factory('User', {}, 5);
|
|
210
|
-
/** Create a CustomModel model */
|
|
211
|
-
await laravel.factory('CustomModel');
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
/** 💾 RUN A DATABASE QUERY */
|
|
215
|
-
await laravel.query('DELETE FROM users');
|
|
216
|
-
/** with bindings */
|
|
217
|
-
await laravel.query('DELETE FROM users WHERE id = ?', [1]);
|
|
218
|
-
/** on a specific connection */
|
|
219
|
-
await laravel.query('DELETE FROM users', [], { connection: 'connection1' });
|
|
220
|
-
/** unprepared statement */
|
|
221
|
-
await laravel.query(`
|
|
222
|
-
DROP SCHEMA public CASCADE;
|
|
223
|
-
CREATE SCHEMA public;
|
|
224
|
-
GRANT ALL ON SCHEMA public TO public;
|
|
225
|
-
`, [], { unprepared: true });
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
/** 🔍 RUN A SELECT QUERY */
|
|
229
|
-
/** returns an array of objects */
|
|
230
|
-
const blogs = await laravel.select('SELECT * FROM blogs');
|
|
231
|
-
/** with bindings */
|
|
232
|
-
await laravel.select('SELECT * FROM blogs WHERE id = ?', [1]);
|
|
233
|
-
/** on a specific connection */
|
|
234
|
-
await laravel.select('SELECT * FROM blogs', {}, { connection: 'connection1' });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
/** ⚙️ RUN A PHP FUNCTION */
|
|
238
|
-
/**
|
|
239
|
-
* Output is JSON encoded in Laravel and decoded in Playwright
|
|
240
|
-
* The following examples call this function:
|
|
241
|
-
* function sayHello($name) { return "Hello, $name!"; }
|
|
242
|
-
*/
|
|
243
|
-
const funcOutput = await laravel.callFunction('sayHello');
|
|
244
|
-
/** with positional parameters */
|
|
245
|
-
await laravel.callFunction('sayHello', ['John']);
|
|
246
|
-
/** with named parameters */
|
|
247
|
-
await laravel.callFunction('sayHello', { name: 'John' });
|
|
248
|
-
/** static class method */
|
|
249
|
-
await laravel.callFunction("App\\MyAwesomeClass::method");
|
|
282
|
+
```ts
|
|
283
|
+
import { expect } from '@playwright/test';
|
|
284
|
+
import { test as setup } from '@saucebase/laravel-playwright';
|
|
250
285
|
|
|
286
|
+
setup('reset the E2E database', async ({ laravel }) => {
|
|
287
|
+
const result = await laravel.artisan('migrate:fresh --seed');
|
|
288
|
+
expect(result.code).toBe(0);
|
|
251
289
|
});
|
|
290
|
+
```
|
|
252
291
|
|
|
292
|
+
Add projects to your existing `defineConfig` call:
|
|
253
293
|
|
|
294
|
+
```ts
|
|
295
|
+
projects: [
|
|
296
|
+
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
|
297
|
+
{ name: 'chromium', testMatch: /.*\.spec\.ts/, dependencies: ['setup'] },
|
|
298
|
+
],
|
|
254
299
|
```
|
|
255
300
|
|
|
256
|
-
|
|
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.
|
|
257
302
|
|
|
258
|
-
|
|
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.
|
|
259
304
|
|
|
260
|
-
|
|
305
|
+
`await laravel.truncate()` truncates the default connection; pass an array of connection names to select others.
|
|
261
306
|
|
|
262
|
-
|
|
263
|
-
- 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.
|
|
264
|
-
- A context you create yourself (`browser.newContext()`) doesn't get the header.
|
|
307
|
+
## Configuration, time and boot functions
|
|
265
308
|
|
|
266
309
|
```ts
|
|
267
310
|
import { test } from '@saucebase/laravel-playwright';
|
|
311
|
+
import { expect } from '@playwright/test';
|
|
268
312
|
|
|
269
|
-
test('
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
* and tearDown is called (done automatically)
|
|
275
|
-
*/
|
|
276
|
-
await laravel.config('app.timezone', 'America/Sao_Paulo');
|
|
277
|
-
|
|
278
|
-
/** ⏰ TRAVEL TO A TIME */
|
|
279
|
-
/** similar to Laravel's `travelTo` method */
|
|
280
|
-
await laravel.travel('2022-01-01 12:00:00');
|
|
281
|
-
|
|
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');
|
|
282
318
|
});
|
|
283
319
|
```
|
|
284
320
|
|
|
285
|
-
|
|
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.
|
|
286
322
|
|
|
287
|
-
Boot functions
|
|
323
|
+
### Boot functions
|
|
288
324
|
|
|
289
|
-
|
|
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.
|
|
290
326
|
|
|
291
|
-
|
|
327
|
+
For example, create `tests/Support/E2eBoot.php`:
|
|
292
328
|
|
|
293
329
|
```php
|
|
294
330
|
<?php
|
|
295
331
|
|
|
296
|
-
namespace
|
|
332
|
+
namespace Tests\Support;
|
|
297
333
|
|
|
298
|
-
use
|
|
299
|
-
use App\Services\FakePaymentGateway;
|
|
300
|
-
use App\Services\Mailer;
|
|
301
|
-
use App\Services\NullMailer;
|
|
334
|
+
use Illuminate\Support\Facades\Mail;
|
|
302
335
|
|
|
303
|
-
class
|
|
336
|
+
class E2eBoot
|
|
304
337
|
{
|
|
305
|
-
|
|
306
|
-
* Swap real services for fakes. Called at boot on every request in the test.
|
|
307
|
-
*/
|
|
308
|
-
public static function useFakeServices(): void
|
|
338
|
+
public static function fakeMail(): void
|
|
309
339
|
{
|
|
310
|
-
|
|
311
|
-
app()->bind(Mailer::class, NullMailer::class);
|
|
340
|
+
Mail::fake();
|
|
312
341
|
}
|
|
313
342
|
}
|
|
314
343
|
```
|
|
315
344
|
|
|
316
|
-
|
|
345
|
+
Register it before navigating to the app:
|
|
317
346
|
|
|
318
347
|
```ts
|
|
319
|
-
|
|
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
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
php artisan playwright:env
|
|
369
|
+
```
|
|
320
370
|
|
|
321
|
-
|
|
371
|
+
This writes a copy of your `.env` with its own server and database. Laravel loads one env file, not one on top of another, so the copy keeps everything else your app needs (`APP_KEY`, secrets, mail). Only these change:
|
|
322
372
|
|
|
323
|
-
|
|
324
|
-
|
|
373
|
+
```dotenv
|
|
374
|
+
APP_ENV=e2e
|
|
375
|
+
APP_URL=http://localhost:8100
|
|
376
|
+
DB_CONNECTION=sqlite
|
|
377
|
+
DB_URL="sqlite:///database/e2e.sqlite?transaction_mode=IMMEDIATE&busy_timeout=10000"
|
|
378
|
+
DB_DATABASE=database/e2e.sqlite
|
|
379
|
+
SESSION_DRIVER=file
|
|
380
|
+
CACHE_STORE=file
|
|
381
|
+
QUEUE_CONNECTION=sync
|
|
382
|
+
MAIL_MAILER=log
|
|
383
|
+
REDIS_PREFIX=<your prefix>e2e_
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`DB_URL` carries the SQLite settings that let parallel workers wait for each other. `transaction_mode=IMMEDIATE` needs PHP 8.4 or later; older versions ignore it, so read-then-write transactions can still hit `database is locked`. `busy_timeout` applies on every version.
|
|
325
387
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
388
|
+
To change a value, set it in `.env` with an `E2E_` prefix. It wins over the defaults above and is copied as written, with any `${VARIABLE}` resolved. The prefix follows the environment: `playwright:env browser` reads `BROWSER_*`.
|
|
389
|
+
|
|
390
|
+
```dotenv
|
|
391
|
+
E2E_MAIL_MAILER=smtp
|
|
392
|
+
|
|
393
|
+
# A real database, e.g. MySQL in Docker. Create it first.
|
|
394
|
+
E2E_DB_CONNECTION=mysql
|
|
395
|
+
E2E_DB_HOST=127.0.0.1
|
|
396
|
+
E2E_DB_PORT=3307
|
|
397
|
+
E2E_DB_DATABASE=${APP_SLUG}_e2e
|
|
398
|
+
E2E_DB_USERNAME=root
|
|
399
|
+
E2E_DB_PASSWORD=password
|
|
400
|
+
```
|
|
329
401
|
|
|
330
|
-
|
|
331
|
-
await page.fill('[name="card_number"]', '4242424242424242');
|
|
332
|
-
await page.click('button[type="submit"]');
|
|
402
|
+
**The suite wipes this database**, so the command refuses one that is also the app's. On SQLite, `E2E_DB_DATABASE` keeps the lock settings. `E2E_DB_URL` is used exactly as written, so include them yourself or parallel workers can hit `database is locked`.
|
|
333
403
|
|
|
334
|
-
|
|
404
|
+
If the file exists, the command asks whether to back it up and replace it, replace it, or keep it. `--backup` and `--force` answer without asking, and an unattended run without either keeps the file. The backup is `.env.e2e.backup`, replaced by the next one. It holds your secrets, so keep it out of git too.
|
|
335
405
|
|
|
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`.
|
|
407
|
+
|
|
408
|
+
### Server options
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
import { defineConfig } from '@playwright/test';
|
|
412
|
+
import { laravelServer, type LaravelOptions } from '@saucebase/laravel-playwright';
|
|
413
|
+
|
|
414
|
+
export default defineConfig<LaravelOptions>({
|
|
415
|
+
use: { baseURL: 'http://localhost:8100' },
|
|
416
|
+
webServer: laravelServer(), // migrate, then php artisan serve --env=e2e on APP_URL's port
|
|
336
417
|
});
|
|
337
418
|
```
|
|
338
419
|
|
|
339
|
-
|
|
340
|
-
|
|
420
|
+
`laravelServer({ env, host, workers, cwd, migrate })` returns a Playwright `webServer` entry. It serves the app on the port of `.env.e2e`'s `APP_URL` and waits on that URL, because an app that tells tenants apart by host answers 404 on any other. It migrates the database first (creating the SQLite file if needed), because an app that reads the database on every request, such as a tenant lookup at boot, can't be migrated over HTTP while its tables are missing. Pass `migrate: false` to skip that. It sets `APP_ENV` and `PHP_CLI_SERVER_WORKERS` (4 by default) for the server, and reuses a running one outside CI. It gives the server its own config, route and event caches (`bootstrap/cache/e2e/`). A config you cached with `config:cache` would otherwise skip every env file and give the server your dev database. It refuses to start if:
|
|
421
|
+
|
|
422
|
+
- `.env.e2e` is missing, because Laravel would then fall back to `.env`.
|
|
423
|
+
- `APP_URL` is missing or not `http:`. `artisan serve` has no TLS.
|
|
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.
|
|
425
|
+
|
|
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`.
|
|
428
|
+
|
|
429
|
+
## Security and custom endpoints
|
|
430
|
+
|
|
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.
|
|
432
|
+
|
|
433
|
+
To customize the defaults:
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
php artisan vendor:publish --tag=laravel-playwright-config
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
The published `config/laravel-playwright.php` contains:
|
|
440
|
+
|
|
441
|
+
```php
|
|
442
|
+
return [
|
|
443
|
+
'prefix' => env('PLAYWRIGHT_PREFIX', 'playwright'),
|
|
444
|
+
'environments' => ['e2e', 'testing'],
|
|
445
|
+
'secret' => env('PLAYWRIGHT_SECRET', null),
|
|
446
|
+
];
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
For a custom prefix, set `use.laravelBaseUrl` to the complete endpoint base URL, for example `http://localhost:8100/test-api`.
|
|
450
|
+
|
|
451
|
+
### Optional shared secret
|
|
452
|
+
|
|
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.
|
|
454
|
+
|
|
455
|
+
Node does not automatically load Laravel's env file. You can read the value without loading the entire file into the process environment:
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
import { readFileSync } from 'node:fs';
|
|
459
|
+
import { parseEnv } from 'node:util';
|
|
460
|
+
|
|
461
|
+
const e2e = parseEnv(readFileSync('.env.e2e', 'utf8'));
|
|
462
|
+
|
|
463
|
+
// Inside defineConfig:
|
|
464
|
+
use: {
|
|
465
|
+
baseURL: e2e.APP_URL,
|
|
466
|
+
laravelSecret: e2e.PLAYWRIGHT_SECRET,
|
|
467
|
+
},
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Keep the test server bound to loopback when possible. A secret does not make these endpoints appropriate for a production deployment.
|
|
471
|
+
|
|
472
|
+
## CI
|
|
473
|
+
|
|
474
|
+
After checking out the app and installing a compatible PHP and Node version, a typical Linux CI job runs:
|
|
475
|
+
|
|
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
|
|
485
|
+
```
|
|
486
|
+
|
|
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.
|
|
488
|
+
|
|
489
|
+
## Troubleshooting
|
|
341
490
|
|
|
342
|
-
|
|
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. |
|
|
343
503
|
|
|
344
|
-
##
|
|
504
|
+
## Laravel Boost
|
|
345
505
|
|
|
346
|
-
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`.
|
|
347
507
|
|
|
348
|
-
##
|
|
508
|
+
## Upgrading from v1
|
|
349
509
|
|
|
350
|
-
-
|
|
351
|
-
-
|
|
352
|
-
-
|
|
353
|
-
-
|
|
354
|
-
-
|
|
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.
|
|
355
516
|
|
|
356
|
-
##
|
|
517
|
+
## Credits
|
|
357
518
|
|
|
358
|
-
|
|
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