88 * wp-ajax-test --url https://site.local --action my_ajax_action
99 * wp-ajax-test --url https://site.local --action my_ajax_action --data '{"key":"value"}'
1010 * wp-ajax-test --url https://site.local --action my_ajax_action --auth temp/auth.json
11+ * wp-ajax-test --url https://site.local --action my_ajax_action --auth-state temp/playwright/.auth/admin.json
1112 */
1213
1314const { program } = require ( 'commander' ) ;
@@ -39,7 +40,8 @@ program
3940 . requiredOption ( '-u, --url <url>' , 'WordPress site URL' )
4041 . requiredOption ( '-a, --action <action>' , 'AJAX action name' )
4142 . option ( '-d, --data <json>' , 'JSON data payload' , '{}' )
42- . option ( '--auth <file>' , 'Auth file path (JSON)' , null )
43+ . option ( '--auth <file>' , 'Auth file path with username/password (JSON) — prefer --auth-state' , null )
44+ . option ( '--auth-state <file>' , 'Playwright auth state file from pw-auth (no plaintext passwords)' , null )
4345 . option ( '-f, --format <format>' , 'Output format (human|json)' , 'human' )
4446 . option ( '--admin' , 'Send the default authenticated/admin-style request flow' , true )
4547 . option ( '--nopriv' , 'Send an unauthenticated request and skip auth/nonce discovery' )
@@ -178,31 +180,44 @@ async function main() {
178180 throw new Error ( `Invalid JSON data: ${ e . message } ` ) ;
179181 }
180182
181- // Load authentication if provided
183+ // Auth: prefer --auth-state (pw-auth cookies) over --auth (plaintext credentials)
182184 let auth = null ;
183- if ( options . auth && useAuthenticatedFlow ) {
185+ let authenticatedViaCookies = false ;
186+
187+ if ( options . authState && useAuthenticatedFlow ) {
188+ // Load pre-authenticated cookies from Playwright auth state
189+ const cookieCount = loadAuthState ( options . authState , options . url ) ;
190+ authenticatedViaCookies = true ;
191+ if ( options . verbose ) {
192+ console . log ( `🔐 Loaded ${ cookieCount } cookies from auth state: ${ options . authState } ` ) ;
193+ }
194+ if ( options . auth ) {
195+ console . error ( '⚠️ --auth-state takes precedence over --auth. Ignoring --auth.' ) ;
196+ }
197+ } else if ( options . auth && useAuthenticatedFlow ) {
198+ console . error ( '⚠️ --auth uses plaintext credentials. Consider pw-auth login + --auth-state instead.' ) ;
184199 auth = await loadAuth ( options . auth ) ;
185200 if ( options . verbose ) {
186201 console . log ( `Loaded auth from: ${ options . auth } ` ) ;
187202 }
188203 }
189204
190- if ( options . auth && ! useAuthenticatedFlow && options . verbose ) {
191- console . log ( 'Ignoring -- auth because --nopriv sends the request without authentication' ) ;
205+ if ( ( options . auth || options . authState ) && ! useAuthenticatedFlow && options . verbose ) {
206+ console . log ( 'Ignoring auth options because --nopriv sends the request without authentication' ) ;
192207 }
193208
194209 if ( ! useAuthenticatedFlow && options . nonceUrl && options . verbose ) {
195210 console . log ( 'Ignoring --nonce-url because --nopriv skips authenticated nonce discovery' ) ;
196211 }
197212
198- // Authenticate if needed
213+ // Authenticate with username/password if using legacy --auth (not needed for --auth-state)
199214 if ( auth && auth . username && auth . password ) {
200215 await authenticate ( options . url , auth ) ;
201216 }
202217
203- // Get nonce if authenticated
218+ // Get nonce if authenticated (works with both --auth-state cookies and --auth login)
204219 let nonce = null ;
205- if ( auth ) {
220+ if ( auth || authenticatedViaCookies ) {
206221 nonce = await getNonce ( options . url , auth , options . nonceUrl , options . nonceField ) ;
207222 if ( options . verbose && nonce ) {
208223 console . log ( `Extracted nonce: ${ nonce . substring ( 0 , 10 ) } ...` ) ;
@@ -261,7 +276,7 @@ async function main() {
261276}
262277
263278/**
264- * Load authentication from file
279+ * Load authentication from file (plaintext username/password)
265280 */
266281async function loadAuth ( authFile ) {
267282 try {
@@ -276,6 +291,59 @@ async function loadAuth(authFile) {
276291 }
277292}
278293
294+ /**
295+ * Load cookies from a Playwright auth state file (from pw-auth).
296+ * Filters cookies by the target site domain and populates the global cookies object.
297+ * Returns the number of cookies loaded.
298+ */
299+ function loadAuthState ( authStateFile , siteUrl ) {
300+ const authStatePath = path . resolve ( authStateFile ) ;
301+ if ( ! fs . existsSync ( authStatePath ) ) {
302+ throw new Error ( `Auth state file not found: ${ authStatePath } ` ) ;
303+ }
304+
305+ let state ;
306+ try {
307+ state = JSON . parse ( fs . readFileSync ( authStatePath , 'utf8' ) ) ;
308+ } catch ( e ) {
309+ throw new Error ( `Failed to parse auth state file: ${ e . message } ` ) ;
310+ }
311+
312+ if ( ! state || ! Array . isArray ( state . cookies ) ) {
313+ throw new Error ( 'Auth state file does not contain a cookies array. Expected Playwright storageState format.' ) ;
314+ }
315+
316+ const targetHost = new URL ( siteUrl ) . hostname ;
317+ const now = Date . now ( ) / 1000 ;
318+ let loaded = 0 ;
319+
320+ for ( const cookie of state . cookies ) {
321+ if ( ! cookie . name || typeof cookie . value !== 'string' ) continue ;
322+
323+ // Match domain: Playwright stores "site.local" (no leading dot)
324+ const cookieDomain = ( cookie . domain || '' ) . replace ( / ^ \. / , '' ) ;
325+ if ( cookieDomain !== targetHost ) continue ;
326+
327+ // Skip expired cookies
328+ if ( cookie . expires && cookie . expires > 0 && cookie . expires < now ) continue ;
329+
330+ cookies [ cookie . name ] = cookie . value ;
331+ loaded ++ ;
332+ }
333+
334+ if ( loaded === 0 ) {
335+ throw new Error ( `No valid cookies found for ${ targetHost } in auth state file. Run pw-auth login first.` ) ;
336+ }
337+
338+ // Verify we have a wordpress_logged_in cookie
339+ const hasLoggedInCookie = Object . keys ( cookies ) . some ( k => k . startsWith ( 'wordpress_logged_in_' ) ) ;
340+ if ( ! hasLoggedInCookie ) {
341+ throw new Error ( `Auth state has cookies for ${ targetHost } but no wordpress_logged_in_* cookie. Session may have expired — run pw-auth login --force.` ) ;
342+ }
343+
344+ return loaded ;
345+ }
346+
279347/**
280348 * Authenticate with WordPress
281349 */
@@ -513,14 +581,17 @@ function handleError(error, format) {
513581 } ;
514582
515583 // Add specific suggestions based on error
516- if ( error . message . includes ( 'Auth file not found' ) ) {
584+ if ( error . message . includes ( 'Auth file not found' ) || error . message . includes ( 'Auth state file not found' ) ) {
517585 errorObj . error . code = 'AUTH_REQUIRED' ;
518- errorObj . suggestions . push ( 'Create temp/auth.json with username and password' ) ;
519- errorObj . suggestions . push ( 'Use --auth flag to specify auth file location' ) ;
586+ errorObj . suggestions . push ( 'Run: pw-auth login --site-url <url> --wp-cli "local-wp <site>"' ) ;
587+ errorObj . suggestions . push ( 'Then: --auth-state temp/playwright/.auth/admin.json' ) ;
588+ } else if ( error . message . includes ( 'No valid cookies found' ) || error . message . includes ( 'no wordpress_logged_in_' ) ) {
589+ errorObj . error . code = 'AUTH_EXPIRED' ;
590+ errorObj . suggestions . push ( 'Run: pw-auth login --site-url <url> --force' ) ;
520591 } else if ( error . message . includes ( 'Authentication failed' ) ) {
521592 errorObj . error . code = 'AUTH_FAILED' ;
522593 errorObj . suggestions . push ( 'Check username and password in auth file' ) ;
523- errorObj . suggestions . push ( 'Verify WordPress site URL is correct ' ) ;
594+ errorObj . suggestions . push ( 'Consider switching to --auth-state with pw-auth (no plaintext passwords) ' ) ;
524595 } else if ( error . message . includes ( 'ENOTFOUND' ) || error . message . includes ( 'ECONNREFUSED' ) ) {
525596 errorObj . error . code = 'CONNECTION_ERROR' ;
526597 errorObj . suggestions . push ( 'Check if WordPress site is running' ) ;
0 commit comments