@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 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
- 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)
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
- On Playwright side, install the package via npm:
43
+ Laravel discovers the provider automatically. To publish the configuration:
23
44
 
24
45
  ```bash
25
- npm install @saucebase/laravel-playwright --save-dev
46
+ php artisan vendor:publish --tag=laravel-playwright-config
26
47
  ```
27
48
 
28
- ## ⚙️ Laravel Config
49
+ This creates `config/laravel-playwright.php`.
29
50
 
30
- Publish the config file:
51
+ ### 2. Create the test environment
31
52
 
32
53
  ```bash
33
- php artisan vendor:publish --tag=laravel-playwright-config
54
+ php artisan playwright:env
34
55
  ```
35
56
 
36
- This creates `config/laravel-playwright.php` with the following options:
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
- ```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'),
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
- * 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
- ];
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
- ## 💡 Example
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
- If you need a good and more complex example of a repository using this package, look at https://github.com/saucebase-dev/saucebase
90
+ Keep `baseURL` equal to `APP_URL` in `.env.e2e`. The client automatically uses `${baseURL}/playwright` for its testing endpoints.
64
91
 
65
- ## 🎭 Playwright Config
92
+ ### 4. Write your first test
66
93
 
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).
94
+ Create `tests/e2e/example.spec.ts`:
68
95
 
69
96
  ```ts
70
- export default defineConfig({
71
- /** ...other config */
72
- use: {
73
- laravelBaseUrl: 'http://localhost/playwright',
74
- laravelSecret: process.env.PLAYWRIGHT_SECRET, // optional
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
- If you use TypeScript, include the `LaravelOptions` type in the `defineConfig` function.
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
- import type { LaravelOptions } from '@saucebase/laravel-playwright';
151
+ const result = await laravel.artisan('db:seed --class=DatabaseSeeder');
152
+ expect(result.code).toBe(0);
153
+ ```
83
154
 
84
- export default defineConfig<LaravelOptions>({
85
- use: {
86
- laravelBaseUrl: 'http://localhost/playwright',
87
- laravelSecret: process.env.PLAYWRIGHT_SECRET, // optional
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
- ## 🏝️ A dedicated environment
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
- 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`.
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
- **2. Let Playwright start the server:**
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
- **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`.
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
- **Laravel `.env`:**
164
- ```
165
- PLAYWRIGHT_SECRET=some-secret
166
- ```
429
+ ## Security and custom endpoints
167
430
 
168
- **`playwright.config.ts`:**
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
- 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).
433
+ To customize the defaults:
176
434
 
177
- ## 🧪 Setting up tests
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
- > **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.
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
- ## 🔄 Dynamic Configuration
449
+ For a custom prefix, set `use.laravelBaseUrl` to the complete endpoint base URL, for example `http://localhost:8100/test-api`.
275
450
 
276
- You can update Laravel config for every subsequent request **of the same test**, until the test ends.
451
+ ### Optional shared secret
277
452
 
278
- 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.
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
- - 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.
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 { test } from '@saucebase/laravel-playwright';
458
+ import { readFileSync } from 'node:fs';
459
+ import { parseEnv } from 'node:util';
286
460
 
287
- test('example', async ({ laravel }) => {
461
+ const e2e = parseEnv(readFileSync('.env.e2e', 'utf8'));
288
462
 
289
- /** 🔧 SET DYNAMIC CONFIG */
290
- /**
291
- * Persists for all subsequent requests until the test ends
292
- * and tearDown is called (done automatically)
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
- ## 🔁 Boot Functions
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
- namespace App;
472
+ ## CI
315
473
 
316
- use App\Services\PaymentGateway;
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
- class E2EHelpers
322
- {
323
- /**
324
- * Swap real services for fakes. Called at boot on every request in the test.
325
- */
326
- public static function useFakeServices(): void
327
- {
328
- app()->bind(PaymentGateway::class, FakePaymentGateway::class);
329
- app()->bind(Mailer::class, NullMailer::class);
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
- **2. Register it in your Playwright test:**
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
- > **Why not use `callFunction` instead?**
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
- > **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. |
361
503
 
362
- ## 🤖 Laravel Boost
504
+ ## Laravel Boost
363
505
 
364
- 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`.
365
507
 
366
- ## ⬆️ Upgrading from v1
508
+ ## Upgrading from v1
367
509
 
368
- - **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.
369
- - **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.
370
- - `DynamicConfig::set()`, `get()`, `getAll()`, `load()` and `delete()` now take the test id as their first argument.
371
- - **Artisan commands run from the application root**, like the CLI, rather than the web server's `public/`.
372
- - 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.
373
516
 
374
- ## 🙏 Credits
517
+ ## Credits
375
518
 
376
- 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.3.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": {