android: turned over, for real — the walk rotates the phone, and puts it back

Round twenty-seven wrote the landscape layout and proved it at 915×411 in
headless Chrome; no real device had ever been turned, and the phone walk
refuses a `{ view: [...] }` step because a device's orientation is not the
step list's to set. That refusal stays. The turn goes in the phone tail
instead, right after `background` has proved playback is live with the app
off screen, and it asserts the four things the mock physically cannot show:
`screen.orientation.angle` is 90 or 270 — an override without
`screenOrientation` leaves the angle at 0, so this one is only ever a real
phone — the WebView RESIZED as well (a separate clause, because a WebView
that never got the configuration change looks exactly like a display that
never rotated), `--nav-h` computed to 48px so the phone-sized landscape query
really fired, and the activity was not recreated: the same window token, the
same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and
those last two are read tolerantly, because `pidof` exits 1 when the process
is GONE and `adb()` throws on a non-zero exit, so the plain read threw the
whole report away at exactly the moment it mattered. Then the stack, the
track still playing and past where it was (a track that rolled over during
the turn is still playing, which is all the clause claims), nothing clipped,
and the player sheet sideways in two columns with every control on screen.

The safe-area insets are printed, not asserted. Nobody had ever measured
them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing
in the generated Kotlin touches insets, so `env()` comes straight out of
Chromium's window-inset mapping — and asserting a number nobody has measured
is how a round fails on a correct build. What is asserted is the plumbing, on
both edges, because a hole-punch is only on one of them: whatever `env()`
says, the glass bar, the mini bar, the screen and the sheet spend it.

The phone is Kyle's, so the two rotation settings are read before either is
written and put back from four places — the block's own finally, main's
finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed
clause only walks on but a WebView that stops answering throws clean out of
the loop, and Ctrl-C runs no exit handler at all. The latch clears itself
before it writes, a failed restore hands it back for the next caller, and the
skip paths disarm rather than restore, so a phone that could not be read, one
already sideways and `--no-rotate` all leave it untouched. Nothing else on the
device is written.

One thing beyond the turn: the `{ adb }` step kept its stdout. `am start`
prints "Error: Activity not started, unable to resolve Intent" on stdout and
exits 0, so `share-import` — the step that exists to prove the share intent
resolves — could not fail for its own reason. It can now.
This commit is contained in:
KeelrLeek committed 2026-09-14 07:27:40 -05:00
1 parent 33f32cb4c9
commit 867fd42972
2 files changed
+500 -6

No files matched your search

+63 -4
View File
@@ -80,10 +80,14 @@ turns it off.
The activity declares no `screenOrientation` and handles `orientation` and The activity declares no `screenOrientation` and handles `orientation` and
`screenSize` itself, so turning the phone over is a resize and nothing else — `screenSize` itself, so turning the phone over is a resize and nothing else —
the audio thread, the stack and the screen's scroll all survive it, and the the audio thread, the stack and the screen's scroll all survive it, and the
layout is the only thing that has to answer. Two media queries do that. The layout is the only thing that has to answer. Two media queries do that.
player sheet is the one surface whose portrait shape cannot survive a wide Every claim in that sentence was read off the CSS and proved at 915×411
viewport: its artwork is a square sized by WIDTH, so at 915×411 the cover came behind a viewport override; the phone walk now turns a real device over and
out 879px tall and the title, the lane, the transport and the face buttons sat reads each of them there — the angle, the insets around the cutout and the
gesture pill, the activity, the audio. See **Verifying**. The player sheet
is the one surface whose portrait shape cannot survive a wide viewport: its
artwork is a square sized by WIDTH, so at 915×411 the cover came out 879px
tall and the title, the lane, the transport and the face buttons sat
below the bottom of a sheet that does not scroll — a corner of the cover and no below the bottom of a sheet that does not scroll — a corner of the cover and no
way to pause. Sideways it is two columns: the head across the top, the cover way to pause. Sideways it is two columns: the head across the top, the cover
down the left sized by HEIGHT, and the track, the scrubber, the transport and down the left sized by HEIGHT, and the track, the scrubber, the transport and
@@ -1426,6 +1430,61 @@ through the same client, which is how the walk turns the phone over and back;
the phone walk refuses one, because a real device's orientation is not the the phone walk refuses one, because a real device's orientation is not the
harness's to set. harness's to set.
The device turns itself over instead, in a block of the phone walk's own,
after `background` and before the network — because a step list cannot do
either of the two things this needs. It reads
`settings get system accelerometer_rotation` and `user_rotation` FIRST and
latches the pair; only then does it put auto-rotate off and `user_rotation`
to 1 (a retry at 3 for a device whose natural orientation is landscape), and
it polls for the turn rather than waiting a fixed time. Then it asserts what
only a device can answer: `screen.orientation.angle` is 90 or 270 — an
`Emulation.setDeviceMetricsOverride` without `screenOrientation` leaves the
angle at 0, so the mock physically cannot fake this, and it is the one clean
"a real phone did this" clause — that the WebView RESIZED as well (a separate
clause, because a WebView that never got the configuration change looks
identical to a display that never rotated), that `--nav-h` computed to 48px
so the phone-sized landscape query really fired, that the activity was NOT
recreated (the same window token and the same `performance.timeOrigin`, the
same pid, no `AndroidRuntime` fatal — the manifest declares `orientation` and
`screenSize` handled, so a recreation here would be the bug), that the tab
and every stack are where they were, that the track is still playing and past
where it was, that nothing is clipped, and that the glass bar, the mini bar
and the screen spend whatever `env()` says. Then the player sheet sideways:
two columns, every control on screen, its own side padding. Then back.
The four safe-area insets are printed, not asserted. Nobody had ever measured
them on this phone: `MainActivity` calls androidx's `enableEdgeToEdge()`,
`themes.xml` sets no cutout attribute and nothing in the generated Kotlin
touches insets, so `env()` comes straight out of Chromium's own window-inset
mapping and whether the hole-punch shows up as a non-zero side inset depends
on the cutout mode that picks. The run records the numbers — `insets` as
top/right/bottom/left, `insetsReal`, the app's own `--inset-left/right` beside
them, and `chromePct` — and a later round can assert them once they are known.
Asserting a number nobody has measured is how a round fails on a correct
build.
Putting the phone back is the part that is written carefully, because the
phone is Kyle's. The saved pair is a module-level latch, and the restore is
reached from four places: the rotation block's own `finally`, main's
`finally`, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK. It has to be all
four — a failed clause only pushes a problem and walks on, but `still()` and
the probe call are wrapped in nothing, so a WebView that stops answering
rejects after 45 s and throws clean out of the loop, and Ctrl-C runs no `exit`
handler at all. The latch clears itself before it writes, so only the first
caller acts and a restore that fails hands the latch back for the next one.
The rotation goes back before auto-rotate does, so a phone that had the sensor
in charge gets it back last. A device whose two settings will not read is
skipped with a line rather than forced — `rotation skipped: the phone's
rotation settings would not read` — as is one that is already sideways, and
`--no-rotate` skips the block outright for a run where he is holding the
phone. Nothing else on the device is touched: no download, no room, no
message, no sign-in, nothing written to the config or the library. The one
case no handler covers is a hard kill of the machine, after which auto-rotate
is a Quick Settings tile away.
The stills are `phone-turned.png`, `phone-turned-sheet.png` and
`phone-turned-back.png`.
The phone walk is the same driver on the real thing (plus a step kind of The phone walk is the same driver on the real thing (plus a step kind of
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
key — and a step may carry a `probe` as well, a function whose reading of key — and a step may carry a `probe` as well, a function whose reading of
+437 -2
View File
@@ -2368,7 +2368,7 @@ const PHONE_EXPECT = {
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, "import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"import-file-picker": (i) => !i.half.includes("up") && !i.toast, "import-file-picker": (i) => !i.half.includes("up") && !i.toast,
"import-file-dismissed": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast, "import-file-dismissed": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr), "share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr) && !/^Error/m.test(i.adbOut || ""),
"share-cancel": (i) => i.title.startsWith("Your library"), "share-cancel": (i) => i.title.startsWith("Your library"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1 artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1
&& /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast), && /^[a-z]+:(ready\+art|none):(ready|error):\d+$/.test(i.artFast),
@@ -2475,6 +2475,115 @@ function mediaSessionState() {
} catch { return { owner: "", state: "" }; } } catch { return { owner: "", state: "" }; }
} }
/* ── the phone, turned over: the two settings that do it ───────────────
* `settings get system accelerometer_rotation` and `user_rotation` are the
* only two things on the device this walk ever writes, and both are READ
* before either is written. The saved pair is latched at module scope on
* purpose: the step loop's `still()` and its PROBE call are wrapped in
* nothing at all, so a WebView that stops answering rejects after cdp.mjs's
* timeout and throws clean out of the walk into main's `finally`. That is
* the exact path that would otherwise leave Kyle's phone locked sideways,
* which is why the restore is a latch reachable from four places — the
* rotation block's own finally, main's finally, process 'exit' and a
* signal — rather than a step in a list. */
let rotationSaved = null;
/** Both rotation settings as the device has them now, or null when either
* will not read as something we recognise. null is the SKIP signal: the
* block never writes what it could not read first. `user_rotation` comes
* back as the literal "null" on a phone where it has never been set. */
function rotationSettings() {
try {
const accel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim();
const user = adb("shell", "settings", "get", "system", "user_rotation").toString().trim();
if (!/^\d+$/.test(accel)) return null;
if (!/^\d+$/.test(user) && user !== "null") return null;
return { accel, user };
} catch { return null; }
}
/** Read once and latch. Returns the pair, or null when the device would not
* say — in which case nothing has been written and there is nothing to put
* back. */
function rotationArm() {
const was = rotationSettings();
if (was) rotationSaved = was;
return was;
}
/** Drop the latch WITHOUT writing anything. For the skip paths: the pair has
* been read but nothing has been put, and restoring there would write two
* settings onto Kyle's phone that this walk never changed. */
function rotationDisarm() { rotationSaved = null; }
/** Hold the phone at one rotation. Auto-rotate goes off FIRST: with the
* sensor still in charge it takes `user_rotation` straight back and the
* turn never happens. Returns null, or the message it failed with. */
function rotationPut(n) {
try {
adb("shell", "settings", "put", "system", "accelerometer_rotation", "0");
adb("shell", "settings", "put", "system", "user_rotation", String(n));
return null;
} catch (e) { return e.message; }
}
/** Put both back, once. The latch is cleared BEFORE anything is written, so
* the first caller is the only one that acts and the three behind it are
* no-ops; a restore that fails puts the latch back so the next caller can
* try again. The rotation goes first and auto-rotate last — a phone whose
* auto-rotate was on must hand control back to the sensor only once the
* angle underneath it is the one it started at. Synchronous, because
* `adb()` is spawnSync and nothing asynchronous survives an 'exit'
* handler. Returns true, null when there was nothing to restore, or the
* message it failed with. */
function rotationRestore() {
const was = rotationSaved;
rotationSaved = null;
if (!was) return null;
try {
if (was.user === "null") {
/* never set on this phone: delete it rather than inventing a 0 */
try { adb("shell", "settings", "delete", "system", "user_rotation"); }
catch { adb("shell", "settings", "put", "system", "user_rotation", "0"); }
} else {
adb("shell", "settings", "put", "system", "user_rotation", was.user);
}
adb("shell", "settings", "put", "system", "accelerometer_rotation", was.accel);
return true;
} catch (e) { rotationSaved = was; return e.message; }
}
/** The system's own view of the turn, for the report only. The two settings
* read back, plus the display's current size and rotation out of `dumpsys
* window displays` — whose spelling moves between Android versions, so a
* parse failure comes back empty and is printed, never asserted. */
function rotationSystem() {
const out = { setAccel: "", setUser: "", dispCur: "", dispRot: "" };
try {
out.setAccel = adb("shell", "settings", "get", "system", "accelerometer_rotation").toString().trim();
out.setUser = adb("shell", "settings", "get", "system", "user_rotation").toString().trim();
} catch { /* empty is the reading */ }
try {
const d = adb("shell", "dumpsys", "window", "displays").toString();
out.dispCur = (d.match(/\bcur=(\d+x\d+)/) || [])[1] || "";
out.dispRot = (d.match(/mCurrentRotation=(?:ROTATION_)?(\w+)/) || d.match(/\brotation=(\d+)/) || [])[1] || "";
} catch { /* empty is the reading */ }
return out;
}
/* 'exit' does not run on a signal and a signal does not run 'exit', so
both. Ctrl-C in the middle of the turn is precisely the case that would
otherwise leave the phone sideways until Kyle found the Quick Settings
tile. Registered only for the phone walk — the mock never writes a
setting, and a SIGINT handler on the mock run would change how Ctrl-C
behaves there for nothing. */
if (PHONE) {
process.on("exit", () => { rotationRestore(); });
for (const sig of ["SIGINT", "SIGTERM", "SIGBREAK"]) {
process.on(sig, () => { rotationRestore(); process.exit(130); });
}
}
/* The audio mixer's underrun count for the app's fast track: a dropout the /* The audio mixer's underrun count for the app's fast track: a dropout the
* ear hears is a number here. Read before and after the walk; a rise during * ear hears is a number here. Read before and after the walk; a rise during
* it is a finding. */ * it is a finding. */
@@ -2627,8 +2736,14 @@ async function main() {
} }
for (const s of steps) { for (const s of steps) {
/* a phone step can be an adb command — an intent, a key */ /* a phone step can be an adb command — an intent, a key */
let adbOut = "";
if (s.adb) { if (s.adb) {
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); } /* kept, not discarded: `am start` prints "Error: Activity not
started, unable to resolve Intent" on STDOUT and still exits
0, so a step's own expectation is the only thing that can
tell a resolved intent from an unresolved one. It reaches
the expectation as `adbOut` below. */
try { adbOut = adb(...s.adb).toString().trim(); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
} }
/* a mock step can turn the phone over: the viewport, through the /* a mock step can turn the phone over: the viewport, through the
same override the walk boots with. A real device's orientation same override the walk boots with. A real device's orientation
@@ -2663,6 +2778,7 @@ async function main() {
if (!s.name) continue; if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`)); await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE); const info = await cdp.evaluate(PROBE);
if (adbOut) info.adbOut = adbOut;
/* a step may want the system's own view as well; dumpsys is slow, /* a step may want the system's own view as well; dumpsys is slow,
so only the steps that ask for it pay for it */ so only the steps that ask for it pay for it */
if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } } if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }
@@ -2753,6 +2869,323 @@ async function main() {
console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen)); console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen));
await still(path.join(OUT, `${prefix}shade.png`)); await still(path.join(OUT, `${prefix}shade.png`));
/* ── turned over, for real ────────────────────────────────
Round twenty-seven wrote the landscape layout and proved it
in headless Chrome behind a viewport override. No real device
had ever been turned. The phone walk still refuses a
`{ view: [...] }` step — a device's orientation is not the
step list's to set — so the turn lives here in the tail
instead, where it can hold a latch and put the phone back
from a `finally` whatever happens above it. It lands right
after `background`, which has just asserted the position
advanced by eight seconds with the app off screen: playback
is provably live, which is the best arming state there is.
What only a device can answer, and the override cannot fake:
screen.orientation.angle (an override without
`screenOrientation` leaves it at 0, so 90 is a fact rather
than a viewport), env()'s real insets around the cutout and
the gesture pill, whether the activity survived the
configuration change instead of being recreated, and whether
the audio kept going through it. The turn and the RESIZE are
separate clauses on purpose: a WebView that never got the
configuration change otherwise looks exactly like a display
that never rotated.
What is NOT asserted, because nobody has measured it yet:
the inset numbers themselves. MainActivity calls androidx's
enableEdgeToEdge() and nothing in the generated Kotlin
touches insets, so env() comes straight out of Chromium's own
window-inset mapping and whether the hole-punch shows up as a
non-zero side inset depends on the cutout mode that picks.
The four numbers are REPORTED; what is asserted is the
plumbing — that .screen, .mini and .np spend whatever env()
says, which is the rule the side-inset longhands at the foot
of shell.css exist to win. Ditto chromePct: the first run is
what records the truth, and a later round can tighten it.
The phone is Kyle's. Both settings are read before either is
written and put back from four places; a device whose
settings will not read is skipped rather than forced; and
`--no-rotate` skips the block outright, for a run where he is
holding the phone. */
if (ARGS.includes("--no-rotate")) {
console.log(" -- rotation skipped: --no-rotate");
} else {
/* the phase's own reading, beside PLAYER / STREAM / HERO /
CAT / NET — PROBE is deliberately left alone, because
sibling rounds append their fields to it. env() cannot be
read out of a custom property everywhere, so the ground
truth is a throwaway fixed 0x0 div whose padding IS the
four env()s, read back computed; the app's own
--inset-left/right are read beside it and printed, so a
disagreement between the two shows up in the report
rather than hiding inside an assertion. */
const ROT = `(function () {
var d = document.createElement('div');
d.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;pointer-events:none;'
+ 'padding-top:env(safe-area-inset-top,0px);padding-right:env(safe-area-inset-right,0px);'
+ 'padding-bottom:env(safe-area-inset-bottom,0px);padding-left:env(safe-area-inset-left,0px)';
document.documentElement.appendChild(d);
var cs = getComputedStyle(d);
var ins = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(function (v) { return Math.round(parseFloat(v) || 0); });
d.remove();
var rs = getComputedStyle(document.documentElement);
function box(sel) { var x = document.querySelector(sel); return x ? x.getBoundingClientRect() : null; }
function inside(b) { return !!b && b.height > 0 && b.top >= -1 && b.bottom <= innerHeight + 1 && b.left >= -1 && b.right <= innerWidth + 1; }
function pad(sel, side) { var x = document.querySelector(sel); return x ? Math.round(parseFloat(getComputedStyle(x)[side]) || 0) : -1; }
/* the chrome that is actually on screen: a mini bar under a raised
sheet and a nav under one are translated away on purpose */
var out = [];
['.gbar', '.screen', '.mini:not(.hidden)', '.nav:not(.gone)'].forEach(function (sel) {
var b = box(sel);
if (b && b.height > 0 && !inside(b)) out.push(sel);
});
/* the witness that an activity recreation would destroy: planted on the
first reading, gone with the window if a new one replaces it */
if (!window.__ssTurn) window.__ssTurn = 'turn-' + Date.now() + '-' + Math.round(Math.random() * 1000000);
var p = window.__ss && __ss.player;
var n = window.__ss && __ss.nav;
return {
token: window.__ssTurn,
origin: Math.round(performance.timeOrigin),
w: innerWidth, h: innerHeight,
vp: innerWidth + 'x' + innerHeight,
/* the one thing a viewport override cannot move */
angle: (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : -1,
ori: (screen.orientation && screen.orientation.type) || '',
mq: matchMedia('(orientation: landscape)').matches,
/* the phone-sized landscape query, read off the one variable only
it sets: 60px portrait, 48px sideways */
navH: (rs.getPropertyValue('--nav-h') || '').trim(),
insets: ins,
varLeft: Math.round(parseFloat(rs.getPropertyValue('--inset-left')) || 0),
varRight: Math.round(parseFloat(rs.getPropertyValue('--inset-right')) || 0),
screenPadL: pad('.screen', 'paddingLeft'),
screenPadR: pad('.screen', 'paddingRight'),
npPadL: pad('.sheet.up .np', 'paddingLeft'),
npPadR: pad('.sheet.up .np', 'paddingRight'),
/* both edges, because a hole-punch is on ONE of them: sideways the
cutout is left or right depending on which way the phone turned,
and a check of the left alone would pass by the inset being 0 */
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
miniRight: Math.round(innerWidth - (box('.mini') || { right: innerWidth + 1 }).right),
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
outside: out.join('|'),
sheet: !!document.querySelector('.sheet.up'),
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
tab: n ? n.tab : '',
depth: (function () { try { return Object.keys(n.stacks).map(function (k) { return k + ':' + n.stacks[k].length; }).join(','); } catch (e) { return ''; } })(),
status: p ? p.status : '',
title: p && p.track ? p.track.title : '',
pos: p ? Math.round(p.position) : -1,
};
})()`;
/* 15 s, not cdp.evaluate's 45: if the activity really was
recreated the socket is dead, and this block asks three
times over. A broken device should cost the walk under a
minute, not three. */
const rotAsk = async (expr) => {
const r = await cdp.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }, 15000);
if (r.result && r.result.exceptionDetails) {
throw new Error("page threw: " + (r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text || ""));
}
return r.result?.result?.value;
};
/* a known screen: Home, nothing open, no transition in
flight, so `.screen` is the viewport box and not a sheet
sliding across it */
try {
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__settle()`);
await sleep(900);
} catch { /* the reading below says so */ }
const was = rotationArm();
let start = null;
try { start = await rotAsk(ROT); } catch (e) { start = null; }
if (!was) {
console.log(" -- rotation skipped: the phone's rotation settings would not read");
} else if (!start) {
/* disarmed, not restored: nothing has been written yet,
and putting two settings back that were never changed
is still writing to his phone */
rotationDisarm();
console.log(" -- rotation skipped: the page did not answer before the turn");
} else if (start.w > start.h) {
rotationDisarm();
console.log(" -- rotation skipped: the phone is already sideways (" + start.vp + ")");
} else {
/* "it kept playing" means nothing unless it was playing:
the restart phase's own idiom, and the only call this
block makes into the app */
if (start.status !== "playing") {
try { await cdp.evaluate(`window.__ss && __ss.toggle()`); } catch { /* reported by the clause */ }
await sleep(2500);
try { start = (await rotAsk(ROT)) || start; } catch { /* keep the earlier reading */ }
}
let rotErr = "";
/* polled, never fixed-waited: a real device turns on its
own clock, and reading a slow answer as a failure has
cost this project a round twice. A page that stops
answering breaks the loop rather than paying 15 s
sixteen times over — and that IS the recreated-WebView
reading, which the finally below still restores from. */
const until = async (wide) => {
let last = null;
for (let i = 0; i < 16; i++) {
await sleep(500);
try { last = await rotAsk(ROT); } catch (e) { rotErr = e.message; return last; }
if (last && (wide ? last.w > last.h : last.w < last.h)) return last;
}
return last;
};
/* `adb()` throws on a non-zero exit, and `pidof` exits 1
when the process is GONE — which is the one finding
this block most exists to catch. Read tolerantly, so a
dead app comes back as `alive: false` in the report
rather than as a bare throw that takes every other
clause with it; `logcat -c`, which some devices refuse
to clear, is the same shape before the turn. */
const adbSay = (...a) => { try { return adb(...a).toString(); } catch { return ""; } };
try {
const pid0 = adbSay("shell", "pidof", "com.soulstream.app").trim();
adbSay("logcat", "-c");
const put = rotationPut(1);
if (put) problems.push("ROTATION could not turn the phone: " + put);
let turned = await until(true);
if (!rotErr && !(turned && turned.w > turned.h)) {
/* a device whose natural orientation is landscape
maps 1 the other way; one retry at 3 is cheap
insurance against a false finding */
rotationPut(3);
turned = await until(true);
}
await sleep(700);
if (!rotErr) { try { turned = (await rotAsk(ROT)) || turned; } catch (e) { rotErr = e.message; } }
try { await still(path.join(OUT, `${prefix}turned.png`)); } catch (e) { problems.push("ROTATION still: " + e.message); }
const sys = rotationSystem();
const pid1 = adbSay("shell", "pidof", "com.soulstream.app").trim();
const fatals = (adbSay("logcat", "-d", "-s", "AndroidRuntime:E").match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
const t = turned || {};
const ins = t.insets || [0, 0, 0, 0];
const checks = {
/* only a device moves this */
turned: t.angle === 90 || t.angle === 270,
/* and the WebView got the configuration change */
resized: t.w > t.h && t.mq === true,
/* …down to the phone-sized query, exactly */
layout: t.navH === "48px",
/* and it is still ANSWERING: a reading that went
stale because the socket died is otherwise a
good-looking reading of the moment before */
answering: !rotErr,
alive: !!pid1 && pid1 === pid0 && !fatals,
/* the manifest declares orientation and screenSize
handled, so the activity must NOT have been
recreated: a recreation makes a new window and
a new document, and both of these would move */
kept: t.token === start.token && t.origin === start.origin,
stack: t.tab === start.tab && t.depth === start.depth,
/* a track that rolled over during the turn starts
the next one at zero: still playing, which is
the whole of what this clause claims */
playing: t.status === "playing" && (t.pos >= start.pos || t.title !== start.title),
unclipped: t.outside === "",
/* whatever env() says sideways, the chrome spends
it — the plumbing, not a number nobody has
measured. The `>= 0` clauses are not belt and
braces: pad() answers -1 for an element that is
not there, and -1 clears `>= inset - 1` whenever
the inset is 0, so a missing .screen passed */
plumbing: t.screenPadL >= 0 && t.screenPadR >= 0
&& t.screenPadL >= ins[3] - 1 && t.miniLeft >= 8 + ins[3] - 1
&& t.screenPadR >= ins[1] - 1 && t.miniRight >= 8 + ins[1] - 1,
};
const bad = Object.keys(checks).filter((k) => !checks[k]);
const report = {
vp: t.vp, angle: t.angle, ori: t.ori, navH: t.navH, chromePct: t.chromePct,
insets: ins.join("/"), insetsReal: ins[1] > 0 || ins[3] > 0,
vars: t.varLeft + "/" + t.varRight,
screenPad: t.screenPadL + "/" + t.screenPadR, mini: t.miniLeft + "/" + t.miniRight,
pid: pid1 || null, fatals, sys, err: rotErr, failed: bad.join(","),
};
if (bad.length) problems.push("EXPECT rotation " + JSON.stringify(report));
console.log((bad.length ? " FAIL " : " ok ") + "rotation".padEnd(16) + " " + JSON.stringify(report));
/* the sheet is the one surface whose portrait shape
cannot survive a wide viewport: two columns, every
control on screen, and its own side padding */
if (!rotErr && t.w > t.h) {
try {
await cdp.evaluate(`var m = document.querySelector('.mini'); m && m.click(); true`);
await sleep(1400);
await still(path.join(OUT, `${prefix}turned-sheet.png`));
const sh = (await rotAsk(ROT)) || {};
const sIns = sh.insets || [0, 0, 0, 0];
const sChecks = {
up: sh.sheet === true,
beside: sh.npCols === "beside",
onScreen: sh.npOut === 0,
/* `>= 0` first: pad() answers -1 for an
element that is not there, and -1
clears `>= inset - 1` whenever the
inset is 0 */
plumbing: sh.npPadL >= 0 && sh.npPadR >= 0
&& sh.npPadL >= sIns[3] - 1 && sh.npPadR >= sIns[1] - 1,
};
const sBad = Object.keys(sChecks).filter((k) => !sChecks[k]);
const sReport = { vp: sh.vp, npCols: sh.npCols, npOut: sh.npOut, npPad: sh.npPadL + "/" + sh.npPadR, insets: sIns.join("/"), failed: sBad.join(",") };
if (sBad.length) problems.push("EXPECT rotation-sheet " + JSON.stringify(sReport));
console.log((sBad.length ? " FAIL " : " ok ") + "rotation-sheet".padEnd(16) + " " + JSON.stringify(sReport));
await cdp.evaluate(`window.__ss && __ss.closeSheet()`);
await sleep(800);
} catch (e) { problems.push("ROTATION rotation-sheet: " + e.message); }
} else {
console.log(" -- rotation-sheet skipped: the phone did not turn");
}
} catch (e) {
/* an adb that falls over mid-block, a still that
cannot be taken: reported, never thrown. A throw
out of here would take the network block, the
lock-screen still and the walk's own summary with
it — and the phone is put back either way, because
catch runs before finally. */
problems.push("ROTATION " + e.message);
} finally {
/* whatever happened above — a clause that failed, a
WebView that stopped answering, a throw out of any
of it — the phone goes back first and everything
else is reported afterwards. */
const back = rotationRestore();
if (back !== true) problems.push("ROTATION could not put the phone's rotation settings back: " + back);
rotErr = "";
let home = await until(false);
await sleep(600);
if (!rotErr) { try { home = (await rotAsk(ROT)) || home; } catch (e) { rotErr = e.message; } }
try { await still(path.join(OUT, `${prefix}turned-back.png`)); } catch { /* the phone may be gone */ }
const now = rotationSettings();
const h = home || {};
const bChecks = {
answering: !rotErr,
portrait: h.w > 0 && h.w < h.h,
angle: h.angle === start.angle,
layout: h.navH === start.navH,
kept: h.token === start.token && h.origin === start.origin,
playing: h.status === "playing",
settings: !!now && now.accel === was.accel && now.user === was.user,
};
const bBad = Object.keys(bChecks).filter((k) => !bChecks[k]);
const bReport = { vp: h.vp, angle: h.angle, navH: h.navH, status: h.status, settings: now, wanted: was, err: rotErr, failed: bBad.join(",") };
if (bBad.length) problems.push("EXPECT rotation-back " + JSON.stringify(bReport));
console.log((bBad.length ? " FAIL " : " ok ") + "rotation-back".padEnd(16) + " " + JSON.stringify(bReport));
}
}
}
/* ── the network: a search, then a peer's copy played as it /* ── the network: a search, then a peer's copy played as it
arrives. Only with the app signed in (the stored account signs arrives. Only with the app signed in (the stored account signs
in at boot); skipped, not failed, when it is not. ────────── */ in at boot); skipped, not failed, when it is not. ────────── */
@@ -2873,6 +3306,8 @@ async function main() {
} }
} }
} finally { } finally {
const rotLeft = rotationRestore();
if (rotLeft !== true && rotLeft !== null) problems.push("ROTATION could not put the phone's rotation settings back: " + rotLeft);
if (cdp) cdp.close(); if (cdp) cdp.close();
if (chrome) chrome.kill(); if (chrome) chrome.kill();
} }