Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
317 lines
21 KiB
Plaintext
317 lines
21 KiB
Plaintext
/* ── 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));
|
|
}
|
|
}
|
|
}
|