feat(dev): ask the phone instead of looking at it
The device tier could only take a screenshot and read what Go chose to log, and a screenshot cannot tell a dropped CSS declaration from a missing asset. This adds the third thing: the page's own answer, from the engine that is really rendering it. `make android-screenshot` grabs the screen, `make android-inspect` forwards the WebView's devtools socket, and `make android-eval EXPR=...` evaluates in the real page. Four details are load-bearing. Only a `debuggable` build opens that socket, so the debug build type takes `applicationIdSuffix ".dev"` and installs *beside* the release app -- the two carry different signing certificates, and Android's only remedy for a changed certificate is an uninstall, which takes the user's library with it. Playwright cannot drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`, which it answers "Browser context management is not supported"), so the eval is raw CDP over Node's built-in WebSocket. The socket name carries the pid, so it is resolved per launch rather than written down. And `exec-out`, not `shell`, for the screenshot: a pty translates LF and corrupts the PNG. What it immediately established is why it was worth having. The phone renders in Chrome 113 at 424x439 CSS px -- two years behind every browser the other tiers use, with no Popover API and no relaxed CSS nesting -- so a spec passing at that viewport says nothing about the device, and two conclusions drawn from version numbers alone were wrong. Both are corrected in NOTES.md and the plan.
This commit is contained in:
@@ -35,6 +35,8 @@ cd "$(dirname "$0")/.."
|
||||
AVD="${YJ_AVD:-yj-test}"
|
||||
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
|
||||
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
|
||||
# Where `make android-inspect` forwards the WebView's devtools socket.
|
||||
CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}"
|
||||
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
|
||||
# relative to the *applicationId*, and the scaffold's activity lives in
|
||||
# the Java package `com.wails.app`, which is deliberately not the
|
||||
@@ -264,6 +266,67 @@ cmd_logs() {
|
||||
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
|
||||
}
|
||||
|
||||
# Forward the WebView's devtools socket, so the page can be asked things.
|
||||
#
|
||||
# Only a `debuggable` build opens that socket, and a debug build carries
|
||||
# `applicationIdSuffix ".dev"` precisely so it can be installed *beside*
|
||||
# the release app: the two are signed by different certificates, and
|
||||
# Android's remedy for a certificate change is an uninstall, which takes
|
||||
# the user's library with it. So this looks for the sibling first and the
|
||||
# release id second.
|
||||
#
|
||||
# The socket name carries the pid, which changes on every launch -- which
|
||||
# is why this resolves it rather than documenting a number.
|
||||
cmd_inspect() {
|
||||
need_sdk
|
||||
pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||
|
||||
local pkg pid
|
||||
pid=""
|
||||
|
||||
for pkg in "$PKG.dev" "$PKG"; do
|
||||
pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}')
|
||||
[ -n "$pid" ] && break
|
||||
done
|
||||
|
||||
[ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first"
|
||||
|
||||
"$ADB" forward --remove-all >/dev/null 2>&1 || true
|
||||
"$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \
|
||||
|| die "adb forward failed"
|
||||
|
||||
echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT"
|
||||
echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'"
|
||||
echo " (a release build has no devtools socket: build and install the debug one)"
|
||||
}
|
||||
|
||||
# What the phone is actually showing.
|
||||
#
|
||||
# This tier exists because no other one can see the platform: system
|
||||
# bars, the safe area, the keyboard, an OEM's permission dialog. All of
|
||||
# those are things you have to *look* at, and two of the three faults
|
||||
# found so far were found by reading a picture rather than an assertion
|
||||
# (`android-tier.md`).
|
||||
#
|
||||
# `exec-out` and not `shell`: `adb shell` runs the output through a pty
|
||||
# on some platforms, which translates LF and corrupts the PNG -- for
|
||||
# which the symptom is an image viewer refusing a file that downloaded
|
||||
# perfectly.
|
||||
cmd_screenshot() {
|
||||
need_sdk
|
||||
pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'"
|
||||
|
||||
local out="${1:-}"
|
||||
|
||||
[ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
"$ADB" exec-out screencap -p > "$out" || die "screencap failed"
|
||||
[ -s "$out" ] || die "screencap produced nothing (is the screen locked?)"
|
||||
|
||||
echo "android: screenshot -> $out"
|
||||
}
|
||||
|
||||
# Start the app and assert it is *still the same process* a few seconds
|
||||
# later. "It started" is not the question — a crash-looping app starts
|
||||
# continuously.
|
||||
@@ -316,9 +379,11 @@ stop) cmd_stop ;;
|
||||
install) cmd_install ;;
|
||||
launch) cmd_launch ;;
|
||||
logs) cmd_logs ;;
|
||||
screenshot) cmd_screenshot "${2:-}" ;;
|
||||
inspect) cmd_inspect ;;
|
||||
smoke) cmd_smoke "${2:-10}" ;;
|
||||
*)
|
||||
echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2
|
||||
echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Evaluate an expression inside the app's WebView on a real device.
|
||||
*
|
||||
* The device tier could only ever *look* at the app (a screenshot) or
|
||||
* read what Go chose to log. This is the third thing: the page's own
|
||||
* answer, from the engine that is actually rendering it — which is how
|
||||
* "the icons are missing" stops being a guess about assets and becomes a
|
||||
* computed style.
|
||||
*
|
||||
* Two facts make it work at all. A `debuggable` build calls
|
||||
* `WebView.setWebContentsDebuggingEnabled(true)`, which opens an abstract
|
||||
* unix socket per process (`webview_devtools_remote_<pid>`); `make
|
||||
* android-inspect` forwards it to localhost. And **Playwright cannot use
|
||||
* it** — `connectOverCDP` immediately calls `Browser.setDownloadBehavior`,
|
||||
* which a WebView answers with "Browser context management is not
|
||||
* supported", so the connection dies before the first evaluate. Raw CDP
|
||||
* over Node's built-in WebSocket is a dozen lines and has no such
|
||||
* opinion.
|
||||
*
|
||||
* Usage: node scripts/android-eval.mjs '<js expression>'
|
||||
* make android-eval EXPR='...'
|
||||
*
|
||||
* The expression is evaluated with `awaitPromise`, so an async probe is
|
||||
* fine. Return a string (`JSON.stringify(...)`) for anything structured:
|
||||
* `returnByValue` will not serialise a DOM node.
|
||||
*/
|
||||
const PORT = process.env.YJ_ANDROID_CDP_PORT ?? '9222';
|
||||
const expression = process.argv[2];
|
||||
|
||||
if (!expression) {
|
||||
console.error("usage: node scripts/android-eval.mjs '<js expression>'");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const endpoint = `http://localhost:${PORT}/json`;
|
||||
let targets;
|
||||
|
||||
try {
|
||||
targets = await (await fetch(endpoint)).json();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`android-eval: nothing on :${PORT} (${err.message})\n` +
|
||||
" run 'make android-inspect' first, and check the phone is " +
|
||||
'awake -- wireless adb drops when the screen sleeps',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const page = targets.find((t) => t.type === 'page');
|
||||
|
||||
if (!page) {
|
||||
console.error('android-eval: no page target; is the app in the foreground?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = () => reject(new Error('websocket refused'));
|
||||
});
|
||||
|
||||
const answer = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('evaluate timed out')), 20_000);
|
||||
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
|
||||
if (msg.id !== 1) return;
|
||||
|
||||
clearTimeout(timer);
|
||||
resolve(msg.result);
|
||||
};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression, awaitPromise: true, returnByValue: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.close();
|
||||
|
||||
if (answer.exceptionDetails) {
|
||||
console.error(
|
||||
'android-eval: threw:',
|
||||
answer.exceptionDetails.exception?.description ??
|
||||
answer.exceptionDetails.text,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const value = answer.result?.value;
|
||||
|
||||
console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
||||
Reference in New Issue
Block a user