@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 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
- This repository contains a Laravel and a Playwright library to help you write E2E tests for your Laravel application using [Playwright](https://playwright.dev/). It adds a set of endpoints to your Laravel application to allow Playwright to interact with it. You can do the following from your Playwright tests:
5
+ Use Laravel factories, Artisan commands, database queries, configuration and time travel from your [Playwright](https://playwright.dev/) tests.
5
6
 
6
- - Run artisan commands
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
- ## 📦 Installation
9
+ ## Contents
15
10
 
16
- On Laravel side, install the package via composer:
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
- ```bash
19
- composer require --dev saucebase/laravel-playwright
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
- On Playwright side, install the package via npm:
35
+ ### 1. Install both packages and a browser
23
36
 
24
37
  ```bash
25
- npm install @saucebase/laravel-playwright --save-dev
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
- ## ⚙️ Laravel Config
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` with the following options:
49
+ This creates `config/laravel-playwright.php`.
37
50
 
38
- ```php
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
- * 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'],
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
- ## 💡 Example
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
- If you need a good and more complex example of a repository using this package, look at https://github.com/saucebase-dev/saucebase
69
+ Use a dedicated test database, never your development or production database.
64
70
 
65
- ## 🎭 Playwright Config
71
+ ### 3. Configure Playwright
66
72
 
67
- Set `use.laravelBaseUrl` in your `playwright.config.ts` to the base URL of your testing endpoints (your application URL + the `prefix` from Laravel config).
73
+ Create or update `playwright.config.ts` in the app root:
68
74
 
69
75
  ```ts
70
- export default defineConfig({
71
- /** ...other config */
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
- laravelBaseUrl: 'http://localhost/playwright',
74
- laravelSecret: process.env.PLAYWRIGHT_SECRET, // optional
82
+ baseURL: 'http://localhost:8100',
75
83
  },
84
+ webServer: laravelServer(),
76
85
  });
77
86
  ```
78
87
 
79
- If you use TypeScript, include the `LaravelOptions` type in the `defineConfig` function.
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 type { LaravelOptions } from '@saucebase/laravel-playwright';
97
+ import { expect } from '@playwright/test';
98
+ import { test } from '@saucebase/laravel-playwright';
83
99
 
84
- export default defineConfig<LaravelOptions>({
85
- use: {
86
- laravelBaseUrl: 'http://localhost/playwright',
87
- laravelSecret: process.env.PLAYWRIGHT_SECRET, // optional
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
- ## 🏝️ A dedicated environment
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
- Run the suite on its own server and database, so it never touches your dev app or its data. This works the same however you serve the app day to day: Herd, Valet, Sail, Docker or `artisan serve`.
95
-
96
- **1. Create `.env.e2e`:**
119
+ ### 5. Run it
97
120
 
98
121
  ```bash
99
- php artisan playwright:env
122
+ npx playwright test
100
123
  ```
101
124
 
102
- 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:
125
+ You should see one passing test. For interactive debugging, use `npx playwright test --ui`.
103
126
 
104
- ```dotenv
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
- `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.
129
+ ## API reference
130
+
131
+ All methods below are available on the `laravel` fixture and return promises.
118
132
 
119
- Options: `php artisan playwright:env <environment> --url=http://localhost:9000 --force`. 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`.
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
- **2. Let Playwright start the server:**
146
+ ### Artisan
147
+
148
+ Pass options in the command string:
122
149
 
123
150
  ```ts
124
- import { defineConfig } from '@playwright/test';
125
- import { laravelServer, type LaravelOptions } from '@saucebase/laravel-playwright';
151
+ const result = await laravel.artisan('db:seed --class=DatabaseSeeder');
152
+ expect(result.code).toBe(0);
153
+ ```
126
154
 
127
- export default defineConfig<LaravelOptions>({
128
- use: { baseURL: 'http://localhost:8100' },
129
- webServer: laravelServer(), // migrate, then php artisan serve --env=e2e on APP_URL's port
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
- `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:
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
- - `.env.e2e` is missing, because Laravel would then fall back to `.env`.
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
- **Subdomains and multi-tenancy.** 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`.
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
- ## 🔒 Security
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
- The package supports an optional shared secret to prevent unauthorized access to the testing endpoints.
182
+ ### PHP functions
144
183
 
145
- **Laravel `.env`:**
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
- PLAYWRIGHT_SECRET=some-secret
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
- **`playwright.config.ts`:**
224
+ Create `tests/e2e/fixtures.ts`:
225
+
151
226
  ```ts
152
- use: {
153
- laravelSecret: process.env.PLAYWRIGHT_SECRET,
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
- When configured, the TypeScript client sends the secret as an `X-Playwright-Secret` header on every request. Laravel returns `401 Unauthorized` if the header is missing or doesn't match. When `PLAYWRIGHT_SECRET` is not set, all requests pass through (backwards compatible).
242
+ Then import your fixture in specs:
158
243
 
159
- ## 🧪 Setting up tests
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
- In your Playwright tests, swap the `test` import from `@playwright/test` to `@saucebase/laravel-playwright`.
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
- ```diff
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
- test('example', async ({ laravel }) => {
168
- await laravel.artisan('migrate:fresh');
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
- > **Note**: In practice, it is not recommended to import from `@saucebase/laravel-playwright` directly in every test file if you have many. Instead, create your own [test fixture](https://playwright.dev/docs/test-fixtures) extending `test` from `@saucebase/laravel-playwright` and import that fixture in your tests.
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
- ## 🚀 Basic Usage
276
+ ## Database setup and parallel tests
175
277
 
176
- ```ts
177
- import { test } from '@saucebase/laravel-playwright';
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
- test('example', async ({ laravel }) => {
180
-
181
- /** 🏃 RUN ARTISAN COMMANDS */
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
- ## 🔄 Dynamic Configuration
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
- You can update Laravel config for every subsequent request **of the same test**, until the test ends.
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
- The state is kept per test. The `laravel` fixture gives each test an id and sends it in an `X-Playwright-Test` header, on its own calls and on the browser's requests to the app. The server applies a test's state only to requests that carry its id. Parallel tests never see or remove each other's config, time or boot functions, and requests without the header (your own browser, a queue worker) see none.
305
+ `await laravel.truncate()` truncates the default connection; pass an array of connection names to select others.
261
306
 
262
- - Only requests to the app carry the header: the `baseURL` host and its subdomains. Third-party requests (Stripe, CDNs) are left alone, so no CORS preflight fails. Override the match with the `laravelIsAppUrl: (url: URL) => boolean` option.
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('example', async ({ laravel }) => {
270
-
271
- /** 🔧 SET DYNAMIC CONFIG */
272
- /**
273
- * Persists for all subsequent requests until the test ends
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
- ## 🔁 Boot Functions
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 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.
323
+ ### Boot functions
288
324
 
289
- A common use case is swapping a real external service (payment gateway, mailer, SMS provider) with a fake for the duration of a test.
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
- **1. Create a helper class in your app** (e.g. `app/E2EHelpers.php`):
327
+ For example, create `tests/Support/E2eBoot.php`:
292
328
 
293
329
  ```php
294
330
  <?php
295
331
 
296
- namespace App;
332
+ namespace Tests\Support;
297
333
 
298
- use App\Services\PaymentGateway;
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 E2EHelpers
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
- app()->bind(PaymentGateway::class, FakePaymentGateway::class);
311
- app()->bind(Mailer::class, NullMailer::class);
340
+ Mail::fake();
312
341
  }
313
342
  }
314
343
  ```
315
344
 
316
- **2. Register it in your Playwright test:**
345
+ Register it before navigating to the app:
317
346
 
318
347
  ```ts
319
- import { test } from '@saucebase/laravel-playwright';
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
- test('checkout with fake payment gateway', async ({ laravel, page }) => {
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
- // Register once — runs at boot on every subsequent request this test makes
324
- await laravel.registerBootFunction('App\\E2EHelpers::useFakeServices');
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
- // From here, every page load and API call the browser makes will use the fakes
327
- const user = await laravel.factory('User');
328
- await laravel.artisan('db:seed', ['--class', 'ProductSeeder']);
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
- await page.goto('/checkout');
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
- await expect(page.locator('.order-confirmation')).toBeVisible();
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
- > **Why not use `callFunction` instead?**
340
- > `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.
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
- > **Note:** The helper class only needs to exist in your `e2e` and `testing` environments. You can guard it with an environment check or keep it out of production deployments entirely.
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
- ## 🤖 Laravel Boost
504
+ ## Laravel Boost
345
505
 
346
- The package ships a [Laravel Boost](https://laravel.com/docs/boost) guideline and a `laravel-playwright-development` skill, covering the e2e environment, per-test state, subdomains and common failures. Boost asks before including third-party packages: pick `saucebase/laravel-playwright` in `php artisan boost:install`, or add it to `packages` in `boost.json` and run `php artisan boost:update`.
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
- ## ⬆️ Upgrading from v1
508
+ ## Upgrading from v1
349
509
 
350
- - **The endpoints are off in `local`.** The default `environments` is now `['e2e', 'testing']`. Serve the suite with `APP_ENV=e2e` (see [A dedicated environment](#️-a-dedicated-environment)). If you published the config, update `environments` there.
351
- - **Dynamic config, `travel()` and boot functions are per test.** State moved from `storage/laravel-playwright-config.json` to `storage/framework/playwright/<test-id>.json`, and only applies to requests that carry the test's `X-Playwright-Test` header. The fixture sends the header for you. If you call the endpoints from your own client, send it yourself; `dynamicConfig`, `travel` and `registerBootFunction` return 400 without it.
352
- - `DynamicConfig::set()`, `get()`, `getAll()`, `load()` and `delete()` now take the test id as their first argument.
353
- - **Artisan commands run from the application root**, like the CLI, rather than the web server's `public/`.
354
- - Requires Laravel 12 or 13.
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
- ## 🙏 Credits
517
+ ## Credits
357
518
 
358
- This package was inspired by [hyvor/laravel-playwright](https://github.com/hyvor/laravel-playwright)
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
  }>;
@@ -83,7 +83,7 @@ class f {
83
83
  `);
84
84
  return await n.json();
85
85
  }
86
- async artisan(t, e = []) {
86
+ async artisan(t, e = {}) {
87
87
  return await this.call("/artisan", { command: t, parameters: e });
88
88
  }
89
89
  async truncate(t = []) {
@@ -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=[]){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"})}));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saucebase/laravel-playwright",
3
- "version": "v2.2.0",
3
+ "version": "v2.3.1",
4
4
  "description": "Blends Laravel and Playwright into seamless E2E tests — factories, queries, artisan commands, and time travel.",
5
5
  "license": "MIT",
6
6
  "repository": {