Playwright end-to-end tests and a browser-automation CLI, running on Termux (Android) against the system-installed Chromium — no Playwright browser downloads, no root.
Fork of Jobians/playwright-termux, extended into a working test project: an @playwright/test suite, a YAML flow runner, and an interactive browser shell.
On a fresh Termux:
curl -fsSL https://raw.githubusercontent.com/dhhieu113pro/pwterm/main/install.sh | bashAlready cloned the repo? From its directory:
bash install.shThe script does everything:
- Installs
nodejsandchromium(enabling the x11-repo) if missing. - Runs
npm installin the repo. npm install -g .to put thepwtermcommand on your PATH.- Applies the Termux bin shim (see below), generates
.envwith your detected Chromium path. - Runs
pwterm doctorto confirm Chromium actually launches.
Options: --dir PATH (clone location), --repo URL, --no-global (skip the global CLI). After it finishes:
pwterm open # interactive browser shell
pwterm run examples/login.yaml # run a YAML flow
npm test # run the Playwright test suiteManual setup (or if the script fails)
# 1. Chromium (also enables x11-repo)
pkg install x11-repo
pkg update
pkg install chromium
# 2. Confirm the binary path — package names/paths differ across Termux versions
which chromium-browser 2>/dev/null || which chromium
# 3. Dependencies + CLI
cd pwterm
npm install
npm install -g .
# 4. npm 11 may skip postinstall scripts. If `pwterm` reports a bad interpreter:
node "$(npm root -g)/pwterm/scripts/termux-shim.js"
# 5. Then set .env:
# PLAYWRIGHT_BROWSERS_PATH=0
# CHROMIUM_PATH=/data/data/com.termux/files/usr/bin/chromium-browser
# (no secrets — just the path to your Chromium build)Drive the browser from any directory instead of editing a spec.
pwterm run examples/login.yaml
pwterm run examples/login.yaml --headed --var username=student
pwterm run examples/login.yaml --quietTwo ready-to-run starters: examples/login.yaml (login with assertions) and examples/todomvc.yaml (a minimal add-and-complete flow for a new site).
Flows are YAML (or JSON) with a baseUrl, optional vars, and a list of single-action steps:
baseUrl: https://practicetestautomation.com
vars:
user: student
steps:
- goto: /practice-test-login/
- fill: { selector: '#username', value: '{{user}}' }
- click: '#submit'
- expectUrl: /logged-in-successfully/
- screenshot: artifacts/after-login.pngTemplates support {{name}}, {{env.HOME}} and --var key=value. A failing step saves a full-page screenshot to pwterm-failures/ and exits non-zero.
| Action | Value |
|---|---|
goto |
url, or { url, waitUntil } |
click |
selector |
fill, type |
{ selector, value } (type presses key by key) |
press |
key, or { key, selector } |
wait |
milliseconds |
waitFor |
selector, or { selector, state } |
waitForUrl |
url, or /regex/ |
expectText |
{ selector, equals / contains / regex } |
expectTitle |
string, /regex/, or { equals } |
expectUrl |
string (substring), or /regex/ |
expectVisible, expectHidden |
selector |
screenshot |
path, or { path, fullPage, selector } |
pdf |
path (headless only) |
upload |
{ selector, files } |
select, check, uncheck, scroll, setViewport, reload, goBack, eval, log |
see pwterm help |
Flow-level options: baseUrl, vars, timeout, headless, viewport, waitUntil, stealth (disable the stealth layer with stealth: false), storageState (load cookies), saveState (write them after a successful run).
Note on baseUrl: URLs resolve like a browser link, so a leading / is the domain root even when baseUrl has a path. Keep baseUrl to the origin (https://example.com) and put the path in goto.
pwterm open
pw> goto https://practicetestautomation.com/practice-test-login/
pw> fill #username student
pw> fill #password Password123
pw> click #submit
pw> save login.yamlThe browser stays warm between commands. Every action is recorded, so save turns manual poking into a reusable flow. title, url, text [selector] and eval <js> inspect the page without recording.
pwterm shot https://example.com shot.png --full-page
pwterm shot https://example.com page.pdf
pwterm test # pass through to @playwright/test
pwterm test -g "TC2"
pwterm report
pwterm doctorpwterm launches Chromium with a stealth layer on by default, so automation tells that anti-bot pages check first are already handled:
| Leak in stock Playwright | What pwterm does |
|---|---|
navigator.webdriver === true |
Patched on the prototype (own-property fakes are caught by _.has-style checks) → reads false |
--enable-automation-style flags |
Withheld via ignoreDefaultArgs, plus --disable-blink-features=AutomationControlled |
UA contains HeadlessChrome |
Real version read from the binary, UA reads Chrome/<ver> |
| Headless WebGL reports SwiftShader | Masked only when it actually leaks; real GPUs pass through untouched |
permissions.query mismatch on notifications |
Resolves with the real state |
plugins / window.chrome / languages |
Nothing to fix — modern headless Chromium already reports these correctly |
Opt out per command, per flow, or globally:
pwterm run flow.yaml --no-stealth # stock Chromium
PWTERM_NO_STEALTH=1 npm test # stealth off for a session# flow-level opt-out
baseUrl: https://example.com
stealth: false
steps: [...]It applies to the @playwright/test runner as well: playwright.config.js injects the stealth flags and UA, and tests/fixtures.js adds the init script — specs import { test, expect } from ./fixtures (so they get it automatically). PWTERM_NO_STEALTH=1 disables it everywhere.
pwterm doctor reports whether stealth is active (stealth on — automation tells patched). tests/stealth.spec.js verifies all of the above against the real browser.
Limits, honestly: this is a flag + runtime-patch layer, not a recompiled browser. Source-level projects (e.g. CloakBrowser) patch canvas/WebGL/audio/TLS inside Chromium's C++ and pass the hardest suites (Cloudflare Turnstile, reCAPTCHA). pwterm's stealth clears the common client-side tells — verified green end-to-end on bot.sannysoft.com (UA, WebDriver+advanced document probes, Chrome, permissions, plugins, languages, WebGL vendor/renderer) — but not server-side TLS match or CDP-level detection. GPU, screen, and platform stay the real values on purpose — a coherent Termux/ARM identity beats a lopsided fake Windows one.
npm test # all specs
npm test -- tests/login.spec.js
npm test -- -g "TC2" # filter by test name
npm test -- --headed # needs Termux X11 + DISPLAY
npm run report # open the last HTML reportpwterm test runs the same runner from anywhere inside the project.
| Spec | Covers |
|---|---|
tests/example.spec.js |
network + offline DOM/form sanity checks |
tests/login.spec.js |
positive login, invalid username, invalid password |
tests/stealth.spec.js |
stealth layer: no automation tells, opt-out restores stock |
playwright.config.js already injects the system Chromium, so tests need no browser boilerplate:
const { test, expect } = require('@playwright/test');
test('page loads', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});For scripts outside the runner (plain Node, no @playwright/test), keep the launch shape:
require('dotenv/config');
const { chromium } = require('playwright-core');
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH, // system Chromium
headless: true,
args: ['--no-sandbox', '--disable-gpu'],
});playwright bundles browser binaries, and on Android its browser-path logic throws Error: Unsupported platform: android. This setup avoids all of it:
playwright-core— no browser downloads at all.PLAYWRIGHT_BROWSERS_PATH=0— disables Playwright's default cache/platform logic.- System Chromium from the Termux X11 repo, passed via
executablePath.
install.sh automates every part of this, including the bin shim below.
An npm bin symlink starts with #!/usr/bin/env node, and Termux has no /usr/bin/env — that is the bad interpreter error. The package's postinstall detects Termux and replaces the symlink with a small sh wrapper in $PREFIX/bin that hardcodes the absolute node path (see scripts/termux-shim.js).
Newer npm versions gate install scripts behind allowScripts. If the postinstall did not run, install the wrapper once by hand:
node "$(npm root -g)/pwterm/scripts/termux-shim.js"To fix npm bins globally instead, load termux-exec in the parent shell before spawning anything:
echo 'export LD_PRELOAD="$PREFIX/lib/libtermux-exec.so"' >> ~/.bashrc
exec bashExporting it only for the child process does not work — the shell doing the execve must itself run with the preload.
npx playwright/node_modules/.bin/*don't work. Termux has no/usr/bin/env, so every npm bin shebang fails withbad interpreter. Thetestscript calls the CLI directly instead:node ./node_modules/@playwright/test/cli.js test. termux-exec can fix it, butLD_PRELOADmust be set in the parent shell (via~/.bashrcand a new session) — setting it only for the child does nothing.pwtermitself sidesteps the issue with a shim (scripts/termux-shim.js).PLAYWRIGHT_BROWSERS_PATH=0must be in the shell env, not only.env. The runner's registry resolves its cache dir at import time — beforeplaywright.config.js(and itsdotenv) loads. Hence it's exported in the npm scripts. In standalone scripts,require('dotenv/config')on line 1 is early enough.- No
/tmp. Write scratch files to the project dir or$PREFIX/tmp. - Headless needs no display. X11 is only for
--headed/--ui. - Android Chrome is not usable — it lacks the CDP surface Playwright needs. Use Termux's Chromium.
install.sh # one-shot installer (Chromium, node, CLI, shim, .env, doctor)
.env # Termux paths (no secrets)
playwright.config.js # testDir, list reporter, system-Chromium launchOptions + stealth flags
index.js # standalone scrape demo (npm start)
tests/ # specs (+ fixtures.js: stealth context for the runner)
bin/pwterm.js # CLI entry point
src/ # cli, flow runner, interactive shell, doctor, stealth
scripts/ # Termux postinstall shim
examples/login.yaml # sample flow
MIT — see LICENSE. Built on Jobians/playwright-termux — the original Chromium + playwright-core workaround. This repo adds the @playwright/test runner, config, tests, the pwterm CLI, and the Termux notes above.