Automation Scripts

Write, record, schedule and run browser automation jobs with BoxLang scripts.

On this page

Automation Scripts

bx-playwright is not only for tests. Any BoxLang script can drive a browser: fill a form every morning, export a report from a site without an API, scrape a table into JSON, check that a page is up, or capture screenshots and PDFs on a schedule.

// hello.bxs
playwright().browse( ( page ) => {
	page.visit( "https://boxlang.io" )
	println( page.title() )
	page.screenshot( "boxlang.png" )
} )
boxlang hello.bxs

Write a script

A script is a .bxs file. Wrap the work in browse(): it opens a fresh page, runs your code and closes the browser, also when the code throws.

// export.bxs
playwright( { baseURL : "https://app.example.com" } ).browse( ( page ) => {
	page.visit( "/login" )
		.fill( "Email", getSystemSetting( "APP_USER" ) )
		.fill( "Password", getSystemSetting( "APP_PASSWORD" ) )
		.click( "Sign in" )
		.assertPathIs( "/dashboard" )

	page.visit( "/reports" )
	page.waitForDownload( () => page.click( "Export CSV" ), "reports/orders.csv" )
} )
  • Selectors are the text people see: fill( "Email", ... ) finds the field by its label, placeholder or name, and click( "Sign in" ) finds the button or link. See Browsing.
  • Actions wait for elements on their own, and assertions such as assertPathIs() retry until they pass or time out. A step that fails stops the script with a clear error.
  • Read secrets from environment variables with getSystemSetting(), never from the script.

Arguments

cliGetArgs() returns the arguments of the script: options for --name=value flags and positionals for the rest. The first positional is the script itself.

// capture.bxs
args   = cliGetArgs()
target = args.options.url ?: "https://boxlang.io"
output = args.options.out ?: "capture.png"
playwright().screenshot( target, output, { fullPage : true } )
println( "Saved #output#" )
boxlang capture.bxs --url=https://ortussolutions.com --out=ortus.png

Record a script with codegen

Do not write the clicks by hand: record them.

bxPlaywright codegen https://app.example.com --output=job.bxs

A browser opens with the Playwright inspector. Click through the job, close the browser, and job.bxs holds the steps as bx-playwright code. Then:

  1. Look for // TODO translate: comments: steps codegen could not translate. Rewrite them with the Browsing API.
  2. Replace typed passwords and other secrets with getSystemSetting().
  3. Wrap the steps in browse() if they are not, and add an assertion after each important step so a broken job fails where it breaks.

Log in once with a saved session

Logging in on every run is slow, and some sites challenge frequent logins. A saved session logs in once and reuses the cookies and local storage:

pw = playwright( { baseURL : "https://app.example.com" } )

pw.session( "app", ( page ) => {
	page.visit( "/login" )
		.fill( "Email", getSystemSetting( "APP_USER" ) )
		.fill( "Password", getSystemSetting( "APP_PASSWORD" ) )
		.click( "Sign in" )
		.assertPathIs( "/dashboard" )
}, { maxAge : 720 } )

pw.browse( ( page ) => {
	page.visit( "/reports" ).assertSee( "Reports" )
}, { session : "app" } )

The first run logs in and saves the session; the next runs reuse it until it is maxAge minutes old (0, the default, never expires), then log in again. refresh : true always logs in. Sessions live in {home}/sessions, outside your project, because they hold cookies. See Saved sessions.

Get data out

playwright().browse( ( page ) => {
	page.visit( "https://app.example.com/orders" )

	// A table into an array of structs
	var orders = page.locator( "table.orders tbody tr" ).all().map( ( row ) => {
		var cells = row.locator( "td" ).texts()
		return { id : cells[ 1 ], customer : cells[ 2 ], total : cells[ 3 ] }
	} )
	fileWrite( "orders.json", jsonSerialize( orders ) )

	// Single values
	var headline = page.text( "h1" )
	var count    = page.count( ".order" )
} )
  • locator( sel ).texts() returns the visible texts of every match; text( sel ), value( sel ) and attribute( sel, name ) read one element. See Browsing.
  • Files: page.screenshot( path ), page.pdf( path ) and page.waitForDownload( () => page.click( "Export" ), path ).
  • Without a page: playwright().screenshot( url, path ), pdf( url, path ), content( url ) (the HTML after JavaScript ran). See Screenshots, PDFs and Rendering.

Handle failures

Every error has a type, a message and a detail with the fix (see Errors). Catch the ones a job can recover from, and retry the whole job for flaky sites:

function runJob() {
	playwright( "ci" ).browse( ( page ) => {
		page.visit( "https://app.example.com/status" ).assertSee( "All systems operational" )
	} )
}

attempts = 0
while ( true ) {
	attempts++
	try {
		runJob()
		break
	} catch ( "Playwright.Timeout" e ) {
		if ( attempts >= 3 ) {
			rethrow
		}
		println( "Attempt #attempts# timed out, retrying: #e.message#" )
		sleep( 5000 )
	}
}

The ci profile keeps a screenshot, a trace and a video of a failed browse(), so you can see what the page looked like when the job broke: open the trace with bxPlaywright show-trace path/to/trace.zip. Artifacts go to the artifacts.directory setting.

Exit codes

Schedulers and CI systems read the exit code. A script that throws exits with a non zero code; to fail on your own condition, exit explicitly:

if ( !orders.len() ) {
	println( "No orders found" )
	cliExit( 1 )
}

Run it on a server

Scripts run headless by default. On a server or in a container:

# Once: Chromium plus the Linux libraries it needs
bxPlaywright install chromium --with-deps
bxPlaywright doctor

# Every run
boxlang /jobs/export.bxs
  • Choose a behavior with a profile: playwright( "ci" ) for headless runs that keep failure artifacts, playwright( "debug" ) to watch the job in a headed, slowed down browser while you build it. Or set BX_PLAYWRIGHT_PROFILE without touching the script.
  • BX_PLAYWRIGHT_BASEURL, BX_PLAYWRIGHT_BROWSER and BX_PLAYWRIGHT_HEADLESS override those settings per environment. See Configuration.
  • bx-playwright closes every browser it started when the script ends, even when it never called close(), so jobs do not leave browser processes behind. A process killed with kill -9 cannot clean up.

Schedule it

cron

# Every weekday at 07:00, with a log
0 7 * * 1-5  cd /jobs && boxlang export.bxs >> /var/log/jobs/export.log 2>&1

A BoxLang scheduler

To keep the schedule in BoxLang, write a scheduler class and run it with boxlang schedule:

// schedulers/JobsScheduler.bx
class {

	property name="scheduler";

	function configure() {
		scheduler.setSchedulerName( "browser-jobs" )

		scheduler.task( "export-orders" )
			.call( () => {
				playwright( "ci" ).browse( ( page ) => {
					page.visit( "https://app.example.com/orders" )
					page.screenshot( "/jobs/out/orders-#dateFormat( now(), "yyyy-MM-dd" )#.png" )
				} )
			} )
			.everyHour()
	}

	function onAnyTaskError( task, exception ) {
		println( "Task [#task.getName()#] failed: #exception.getMessage()#" )
	}

}
boxlang schedule schedulers/JobsScheduler.bx

The scheduler runs until you stop it (Ctrl+C). To start it with the runtime, list it in the schedulers setting of boxlang.json. Inside a ColdBox application, use its scheduler and call the same code from a task. See the BoxLang scheduled tasks guide.

  • Each run starts its own browser inside browse(). Playwright is not thread safe: never share a page or a manager between tasks that run at the same time.
  • Use absolute paths for files a scheduled task writes: relative paths do not resolve against the directory you started the scheduler from.
  • Date masks are case sensitive: yyyy-MM-dd is the date, mm is minutes.

A complete job

// jobs/daily-orders.bxs: export yesterday's orders to JSON, with a screenshot of the page
pw = playwright( "ci", { baseURL : getSystemSetting( "APP_URL" ) } )

pw.session( "app", ( page ) => {
	page.visit( "/login" )
		.fill( "Email", getSystemSetting( "APP_USER" ) )
		.fill( "Password", getSystemSetting( "APP_PASSWORD" ) )
		.click( "Sign in" )
		.assertPathIs( "/dashboard" )
}, { maxAge : 720 } )

orders = pw.browse( ( page ) => {
	page.visit( "/orders?range=yesterday" ).assertSee( "Orders" )
	page.screenshot( "out/orders.png", { fullPage : true } )
	return page.locator( "table.orders tbody tr" ).all().map( ( row ) => {
		var cells = row.locator( "td" ).texts()
		return { id : cells[ 1 ], customer : cells[ 2 ], total : cells[ 3 ] }
	} )
}, { session : "app" } )

fileWrite( "out/orders.json", jsonSerialize( orders ) )
println( "Exported #orders.len()# orders" )
if ( !orders.len() ) {
	cliExit( 1 )
}
APP_URL=https://app.example.com APP_USER=bot@example.com APP_PASSWORD=*** boxlang jobs/daily-orders.bxs

Let an AI agent do it

When the steps change too often to script, let an agent drive the browser instead: playwright().aiTools() gives bx-ai agents browser tools, and bxPlaywright mcp serves them to Claude and other MCP clients. See AI Agents.

Edit this page Download Markdown Last updated Oct 2, 2026, 4:20:36 PM