diff --git a/ANDROID.md b/ANDROID.md index 72ad644..3247832 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -80,10 +80,14 @@ turns it off. The activity declares no `screenOrientation` and handles `orientation` and `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 -layout is the only thing that has to answer. Two media queries do that. 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 +layout is the only thing that has to answer. Two media queries do that. +Every claim in that sentence was read off the CSS and proved at 915×411 +behind a viewport override; the phone walk now turns a real device over and +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 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 @@ -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 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 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 diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 02cc350..51ed249 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -2368,7 +2368,7 @@ const PHONE_EXPECT = { "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-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"), 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), @@ -2475,6 +2475,115 @@ function mediaSessionState() { } 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 * ear hears is a number here. Read before and after the walk; a rise during * it is a finding. */ @@ -2627,8 +2736,14 @@ async function main() { } for (const s of steps) { /* a phone step can be an adb command — an intent, a key */ + let adbOut = ""; 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 same override the walk boots with. A real device's orientation @@ -2663,6 +2778,7 @@ async function main() { if (!s.name) continue; await still(path.join(OUT, `${prefix}${s.name}.png`)); 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, 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); } } @@ -2753,6 +2869,323 @@ async function main() { console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen)); 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 arrives. Only with the app signed in (the stored account signs in at boot); skipped, not failed, when it is not. ────────── */ @@ -2873,6 +3306,8 @@ async function main() { } } } 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 (chrome) chrome.kill(); }