test: synchronize rotation and add opt-in screen acceptance

Wait for applied landscape configuration before explicit recreation and
assert the barcode draft after both transitions. Retain focused unlocked
input requirements while allowing brief physical-test focus changes.

Add a manual-only QA device-screen acceptance path with three actual OS
consent rounds, real Home-frame comparison, Stop, lease expiry and forced
logout. Protect existing app IDs/ports and compile QA tests in CI.
Use non-streaming physical installs and actionable timeout diagnostics.

QA and E2E packages compile, runner syntax/actionlint and opt-in/protected
port negative controls pass. Actual screen consent and synchronized
native scenario execution remain pending; no production code changed.

Co-Authored-By: OpenAI GPT-6.1 (Codex) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandOpenAI GPT-6.1 committed 2026-11-01 04:24:15 -06:00
1 parent e04fdb2d21
commit 5ba87f49d2
12 files changed
+552 -59

No files matched your search

+4
View File
@@ -46,6 +46,10 @@ jobs:
:scanner-host:testDebugUnitTest
:scanner-host:lintDebug
:scanner-host:assembleDebug
- name: Compile opt-in physical QA acceptance
run: >-
./gradlew --no-daemon --console=plain -PscannerTestBuildType=qa
:scanner-host:assembleQa :scanner-host:assembleQaAndroidTest
- name: Hash debug APK
run: |
mkdir -p scanner-host/build/outputs/apk/debug/notices
+16
View File
@@ -20,6 +20,22 @@ Evidence is written under `scanner-host/build/native-device-e2e`. Request audits
The standalone `orientation` and `licenses` scenarios need only the isolated scanner APKs and no server. Orientation acceptance checks saved policy, actual portrait/landscape configuration, recreation, and a fresh Activity launch for each choice. The session and photo scenarios separately verify that a rotation retains process input and a pending original.
Physical `session-monitor` now uses the disposable `io.qrun.mobile.scanner.qa` variant so a retained `.e2e` installation is preserved. Build it with `-PscannerTestBuildType=qa :scanner-host:assembleQa :scanner-host:assembleQaAndroidTest`; hosted/emulator sessions continue using E2E. Its rotation check waits for the requested landscape configuration and resumed lifecycle, verifies the draft, then explicitly recreates and verifies the draft again. Brief physical-test focus transitions wait up to five seconds within the existing stage deadline; actual resumed/unlocked/focused requirements remain enforced. Physical installs use ADB's non-streaming path.
## Explicit operator-consented device-screen acceptance
This manual-only test is excluded from hosted/default scenarios. Compile QA packages as above, then run:
```text
node scripts/ci/scanner-screen-device.mjs --serial TC22_SERIAL --fixture PRIVATE_FIXTURE.json --operator-consent
```
The private file must identify `purpose: "device-screen-qa"`, a disposable loopback `baseUrl`, `username: "scanner"`, `adminUsername: "admin"` and fresh private `password`/`adminPassword` values. Use the qualified core/API1.9 and mobile-scanners1.2 runtime; the runner refuses the normal57832 and retained51631 ports. Keep this file owner-only and outside Git. It installs only absent QA/QA.test packages and owns only its new reverse/proxy resources.
Named stages request notification permission, three fresh Android projection approvals, Home, Stop and a return through Recents. The operator handles every Android permission dialog; the test never grants them. It checks real app/Home pixels, Stop, viewer-lease expiry, administrator session end and fresh login without resumed projection. Each operator wait is bounded to120seconds. Server JPEGs are compared with the physical display in memory using bounded spatial RGB error and blank/wrong-layout negative controls. Neither screenshot source is retained as evidence or substituted for the application's uploaded frame.
Compilation and runner guard checks pass; actual system consent/display acceptance remains unexecuted. A failed run records FAILED rather than leaving a successful-looking proof, preserves sanitized stage/operation evidence and removes only its disposable resources. Successful execution requires every completion stage and successful native semantic screen uploads; a decodable JPEG alone is insufficient.
Photo acceptance requires the companion optional Record Files integration's successfully verified isolated fixture. Start `integrations/record-files/Start-ConsumerFixture.ps1` in that repository, keep its returned credential file private, and run:
```powershell
+8
View File
@@ -1,5 +1,13 @@
# Session state
## November 1 morning — normal app updated; native test correction pending hosted verification
The normal TC22 application is now updated in place to the reviewed development APK `F3AF638E…E9FAA`. Matching signing identity and a new owner-only private-data backup were verified; app data/enrollment were not cleared. Non-streaming installation completed and the normal process is running against the preserved API1.9/mobile1.1 demo. This supersedes older unchanged-normal-APK statements below. Operator login/navigation/scanning confirmation is pending; no production release is claimed.
Published `e04fdb2` passed CircleCI and CodeQL, but both GitHub native runs failed the same session test: it requested rotation then another Activity recreation without waiting for configuration convergence, and the barcode draft was empty. The test now waits for landscape/resumed configuration and asserts the draft after rotation and again after explicit recreation. This is a test synchronization correction awaiting native execution, not a demonstrated production draft fix. Physical barcode attempts separately hit a wrong expected value (operator confirmed the old test code), a transient focus assertion and an ADB timeout; none is a full hardware pass. Focus preconditions now wait briefly within the120second stage deadline, and install timeouts identify the operation.
The opt-in QA screen-consent harness, shared HTTP helpers and both QA/E2E test packages compile. Its early safety checks reject missing operator opt-in and both protected demo ports without device access. Actual consent/Home/Stop/expiry/end-session acceptance remains pending. The harness author stopped at the workspace agent-credit limit; root completed the compile corrections and continues directly. A real Authentik fixture is compiled with pinned images downloaded, but no services or OS trust changes have occurred; temporary Windows CA approval is still pending.
## November 1 — refined device-screen client and published paired server
The refined client passes137 JVM tests, all package/lint gates, fresh four-APK inspection and the TC22 ordinary-UID scan-ingress regression. Continuous image draining retains one latest buffer; repeated Share is blocked through service binding. Fresh independent follow-up review found no concrete issue. The source-pinned1.2 server `1041693` is published and passes39 server/six demo/all15 browser tests with strict195-JAR/2,441-class provenance in a cloned private Maven cache. Hosted client CI now targets that exact source. No actual projection consent/physical trigger is proved; the normal APK remains unchanged and its running demo remains qualified1.1. See [candidate scope and hashes](DEVICE-SCREEN-ACCEPTANCE.md).
+3
View File
@@ -1,5 +1,8 @@
# TODO
- [ ] Verify the synchronized rotation/recreation session test in fresh native execution; both prior GitHub failures lost the draft at the old back-to-back transition.
- [ ] Finish the operator walkthrough on the newly updated normal TC22 app; private data/enrollment and the development signing identity were preserved.
- [x] Compile the opt-in screen-consent QA harness and ordinary E2E tests; verify early opt-in/protected-port safety gates. Actual permission/display acceptance remains open.
- [x] Compile/review additive consent-based device-screen client and paired1.2 server:137 JVM, package/lint/APK inspection, ordinary-UID TC22 scan-input rejection,39 server/six demo/all15 browser cases and strict provenance pass. Actual projection acceptance remains separate.
- [ ] Execute opt-in actual Android consent, notification, Home/background pixels, Stop, lease expiry, lock/rotation and forced-session-end acceptance on the refined device-screen candidate.
- [ ] Resolve CircleCI job 140's three unfocused-Activity Back failures using the new bounded window-owner diagnostics; retain real Back and the five-second guard. CodeQL on `6845abc` passed, but native CI is not fully green.
@@ -37,9 +37,7 @@ import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.TimeUnit
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -181,7 +179,7 @@ class ScannerEndToEndTest
fun sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin()
{
val context = InstrumentationRegistry.getInstrumentation().targetContext
check(context.packageName == "io.qrun.mobile.scanner.e2e")
check(context.packageName in setOf("io.qrun.mobile.scanner.e2e", "io.qrun.mobile.scanner.qa"))
val configFile = File(context.filesDir, "e2e.json")
val config = JSONObject(configFile.readText())
configFile.delete()
@@ -215,8 +213,19 @@ class ScannerEndToEndTest
ui.onNodeWithTag("appHome.rowForProcess:mobileScanCapture").performClick()
waitFor("Manual barcode")
ui.onNodeWithText("Manual barcode").performTextReplacement("orientation-draft")
ui.onNodeWithText("Manual barcode").assertTextContains("orientation-draft")
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
ui.waitUntil(timeoutMillis = 15000)
{
var landscape = false
ui.activityRule.scenario.onActivity {
landscape = it.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE &&
it.lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)
}
landscape
}
waitFor("Manual barcode")
ui.onNodeWithText("Manual barcode").assertTextContains("orientation-draft")
ui.activityRule.scenario.recreate()
waitFor("Manual barcode")
ui.onNodeWithText("Manual barcode").assertTextContains("orientation-draft")
@@ -300,8 +309,7 @@ class ScannerEndToEndTest
private fun sessionRows(): List<JSONObject>
{
val records = jsonRequest("/qqq/v1/table/mobileSession/query", JSONObject()).getJSONArray("records")
return (0 until records.length()).map { records.getJSONObject(it).getJSONObject("values") }
return SessionAcceptanceClient(origin, adminCookie, client).sessions()
}
private fun waitForServer(condition: () -> Boolean)
@@ -320,13 +328,7 @@ class ScannerEndToEndTest
}
}
private fun findImage(value: Any?): String? = when(value)
{
is String -> value.takeIf { it.startsWith("data:image/jpeg;base64,") }
is JSONObject -> value.keys().asSequence().mapNotNull { findImage(value.opt(it)) }.firstOrNull()
is org.json.JSONArray -> (0 until value.length()).firstNotNullOfOrNull { findImage(value.opt(it)) }
else -> null
}
private fun findImage(value: Any?): String? = SessionAcceptanceClient.image(value)
@Test
fun adminFormsApproveRevokeAndCancel()
@@ -508,13 +510,7 @@ class ScannerEndToEndTest
private fun loginAdmin(password: String, account: String = "admin"): String
{
val request = Request.Builder().url("$origin/qqq/v1/manageSession")
.header("Authorization", Credentials.basic(account, password))
.post("{}".toRequestBody("application/json".toMediaType())).build()
return client.newCall(request).execute().use { response ->
assertEquals("Admin setup login must succeed", 200, response.code)
response.headers("Set-Cookie").first { it.startsWith("sessionUUID=") }.substringBefore(';')
}
return SessionAcceptanceClient.login(origin, password, client, account)
}
private fun runAdmin(process: String, deviceId: String)
@@ -527,25 +523,11 @@ class ScannerEndToEndTest
private fun processCall(process: String, suffix: String, values: JSONObject): JSONObject
{
val request = Request.Builder().url("$origin/qqq/v1/processes/$process$suffix").header("Cookie", adminCookie)
.post(MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString())
.addFormDataPart("stepTimeoutMillis", "10000").build()).build()
var result = executeJson(request)
val processId = result.optString("processUUID")
val jobId = result.optString("jobUUID")
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30)
while(result.optString("type") in listOf("JOB_STARTED", "RUNNING"))
{
assertTrue("Process must finish before the deadline", System.nanoTime() < deadline)
Thread.sleep(50)
result = executeJson(Request.Builder().url("$origin/qqq/v1/processes/$process/$processId/status/$jobId").header("Cookie", adminCookie).build())
}
assertTrue("Administrative process must not fail", !result.has("error"))
return result
return SessionAcceptanceClient(origin, adminCookie, client).process(process, suffix, values)
}
private fun jsonRequest(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = executeJson(Request.Builder().url("$origin$path")
.header("Cookie", adminCookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus)
private fun jsonRequest(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject =
SessionAcceptanceClient(origin, adminCookie, client).json(path, body, expectedStatus)
private fun executeJson(request: Request, expectedStatus: Int = 200): JSONObject = client.newCall(request).execute().use { response ->
assertEquals("Test server request must succeed", expectedStatus, response.code)
@@ -0,0 +1,83 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import java.util.concurrent.TimeUnit
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
/** Shared real semantic HTTP fixture operations; never prints credentials or response bodies. */
internal class SessionAcceptanceClient(private val origin: String, private val cookie: String,
private val client: OkHttpClient)
{
fun json(path: String, body: JSONObject, expectedStatus: Int = 200): JSONObject = execute(Request.Builder().url("$origin$path")
.header("Cookie", cookie).post(body.toString().toRequestBody("application/json".toMediaType())).build(), expectedStatus)
fun process(name: String, suffix: String, values: JSONObject): JSONObject
{
var result = execute(Request.Builder().url("$origin/qqq/v1/processes/$name$suffix").header("Cookie", cookie)
.post(MultipartBody.Builder().setType(MultipartBody.FORM).addFormDataPart("values", values.toString())
.addFormDataPart("stepTimeoutMillis", "10000").build()).build())
val id = result.optString("processUUID")
val job = result.optString("jobUUID")
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30)
while(result.optString("type") in listOf("JOB_STARTED", "RUNNING")) {
assertTrue("Fixture process must finish before its deadline", System.nanoTime() < deadline)
Thread.sleep(50)
result = execute(Request.Builder().url("$origin/qqq/v1/processes/$name/$id/status/$job").header("Cookie", cookie).build())
}
assertTrue("Fixture process must not fail", !result.has("error"))
return result
}
fun apply(name: String, values: JSONObject)
{
val id = process(name, "/init", JSONObject()).getString("processUUID")
val applied = process(name, "/$id/step/input", values)
if(applied.optString("nextStep") == "result") process(name, "/$id/step/result", JSONObject())
}
fun sessions(): List<JSONObject>
{
val rows = json("/qqq/v1/table/mobileSession/query", JSONObject()).getJSONArray("records")
return (0 until rows.length()).map { rows.getJSONObject(it).getJSONObject("values") }
}
fun widget(id: String) = json("/qqq/v1/widget/mobileSessionScreen", JSONObject().put("id", id))
fun logout()
{
client.newCall(Request.Builder().url("$origin/qqq/v1/logout").header("Cookie", cookie)
.post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use {
assertTrue("Fixture admin logout must succeed", it.isSuccessful)
}
}
private fun execute(request: Request, expectedStatus: Int = 200) = client.newCall(request).execute().use {
assertEquals("Fixture semantic request must succeed", expectedStatus, it.code)
JSONObject(it.body!!.string())
}
companion object {
fun login(origin: String, password: String, client: OkHttpClient, account: String = "admin"): String =
client.newCall(Request.Builder().url("$origin/qqq/v1/manageSession")
.header("Authorization", Credentials.basic(account, password))
.post("{}".toRequestBody("application/json".toMediaType())).build()).execute().use {
assertEquals("Fixture admin login must succeed", 200, it.code)
it.headers("Set-Cookie").first { value -> value.startsWith("sessionUUID=") }.substringBefore(';')
}
fun image(value: Any?): String? = when(value) {
is String -> value.takeIf { it.startsWith("data:image/jpeg;base64,") }
is JSONObject -> value.keys().asSequence().mapNotNull { image(value.opt(it)) }.firstOrNull()
is org.json.JSONArray -> (0 until value.length()).firstNotNullOfOrNull { image(value.opt(it)) }
else -> null
}
}
}
@@ -2,5 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="io.qrun.mobile.scanner.probe" />
<intent><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.HOME" /></intent>
</queries>
</manifest>
@@ -0,0 +1,311 @@
/* Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0 */
package io.qrun.mobile.scanner
import android.Manifest
import android.app.KeyguardManager
import android.app.NotificationManager
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.util.Base64
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.net.URI
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.sqrt
import okhttp3.OkHttpClient
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Manual-only acceptance. Android permission UI is exclusively operated by the person holding the TC22. */
@RunWith(AndroidJUnit4::class)
class DeviceScreenAcceptanceTest
{
@get:Rule val ui = createAndroidComposeRule<ScannerActivity>()
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext
private val client = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
private lateinit var admin: SessionAcceptanceClient
private lateinit var sessionId: String
@Test fun operatorConsentCapturesHomeAndStopsAtEverySessionBoundary()
{
check(context.packageName == "io.qrun.mobile.scanner.qa" && BuildConfig.BUILD_TYPE == "qa")
check(InstrumentationRegistry.getArguments().getString("operatorConsent") == "true")
check(Build.MANUFACTURER.startsWith("Zebra", true) && Build.MODEL.startsWith("TC22", true))
check(context.getSystemService(PowerManager::class.java).isInteractive &&
!context.getSystemService(KeyguardManager::class.java).isDeviceLocked)
val file = File(context.filesDir, "qa-screen.json")
val config = JSONObject(file.readText())
check(file.delete())
check(config.getString("purpose") == "device-screen-qa")
val server = URI(config.getString("serverUrl"))
check(server.scheme == "http" && server.host == "127.0.0.1" && server.port > 0 &&
server.userInfo == null && server.query == null && server.fragment == null && server.path == "/")
val origin = server.toString().trimEnd('/')
comparatorControls()
waitFor("QQQ server URL")
ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/")
click("Connect")
signIn(config.getString("scannerPassword"))
waitFor("An administrator must approve this device before you can run mobile apps.")
val device = DeviceCredentials(context, "$origin/")
admin = SessionAcceptanceClient(origin, SessionAcceptanceClient.login(origin, config.getString("adminPassword"), client), client)
try
{
admin.apply("mobileDeviceApprove", JSONObject().put("deviceId", device.deviceId))
click("Check approval")
await("approved-catalog") { ui.onAllNodesWithTag("appHome.rowForApp:mobileScanApp").fetchSemanticsNodes().isNotEmpty() }
await("initial-session") {
val current = admin.sessions().singleOrNull { it.optString("deviceId") == device.deviceId && it.optString("status") == "ACTIVE" }
if(current != null) sessionId = current.getString("id")
current != null && current.optString("captureMode") == "APP_CONTENT"
}
consent("initial")
assertDeviceFrame()
stage("screen-app-frame")
val launcher = instrumentation.context.packageManager.resolveActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
PackageManager.MATCH_DEFAULT_ONLY)?.activityInfo?.packageName
assertTrue("An unambiguous Android Home launcher must be available", !launcher.isNullOrBlank() && launcher != context.packageName)
stage("screen-home-ready")
await("operator-Home-and-matching-frame", 120) {
val widget = admin.widget(sessionId) // This is the actual administrator's renewable viewing demand.
val atHome = activePackage() == launcher
atHome && !row().optBoolean("foreground") && row().optString("captureMode") == "DEVICE_SCREEN" &&
matchesDisplay(SessionAcceptanceClient.image(widget))
}
stage("screen-home-verified")
stage("screen-stop-ready")
await("operator-system-Stop", 120) {
// Reading the widget keeps demand alive: only the operator's Stop may end this round.
val image = SessionAcceptanceClient.image(admin.widget(sessionId))
!row().optBoolean("screenAvailable") && image == null && !projectionNotification()
}
stage("screen-stop-verified")
stage("screen-return-app-ready")
await("operator-return-via-Recents", 120) { activityFocused() }
waitFor("Screen viewing paused")
consent("expiry")
assertDeviceFrame()
// Deliberately query ONLY the session table here. A widget read would renew the lease under test.
await("viewer-lease-expiry", 60) { !row().optBoolean("screenAvailable") && !projectionNotification() }
assertTrue("Expired viewing demand must purge its frame", SessionAcceptanceClient.image(admin.widget(sessionId)) == null)
waitFor("Screen viewing paused")
stage("screen-expiry-verified")
consent("logout")
assertDeviceFrame()
admin.apply("mobileSessionEnd", JSONObject().put("sessionId", sessionId))
await("administrator-session-end") {
row().optString("status") == "ENDED" && !projectionNotification() &&
SessionAcceptanceClient.image(admin.widget(sessionId)) == null
}
waitFor("Log back in")
stage("screen-logout-verified")
click("Log back in")
signIn(config.getString("scannerPassword"))
await("fresh-session-without-projection") {
val fresh = admin.sessions().singleOrNull { it.optString("deviceId") == device.deviceId && it.optString("status") == "ACTIVE" }
fresh != null && fresh.getString("id") != sessionId && fresh.optString("captureMode") == "APP_CONTENT" && !projectionNotification()
}
stage("screen-fresh-login-verified")
}
finally { admin.logout() }
}
private fun consent(round: String)
{
assertFalse("Each round must begin without a running projection", projectionNotification())
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.settings").performClick()
if(Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
{
waitFor("Enable sharing notification")
stage("screen-notification-permission-ready")
click("Enable sharing notification") // Own app requests permission; the operator grants/denies Android's dialog.
await("operator-notification-permission", 120) {
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED && activePackage() == context.packageName
}
}
assertTrue("Sharing requires enabled notifications", context.getSystemService(NotificationManager::class.java).areNotificationsEnabled())
waitFor("Share device screen…")
click("Share device screen…")
waitFor("Share this device screen?")
stage("screen-consent-$round-ready")
click("Continue to Android permission") // Never inspect or click the system permission window.
await("operator-consent-$round", 120) {
admin.widget(sessionId)
row().optString("captureMode") == "DEVICE_SCREEN" && row().optBoolean("screenAvailable") && projectionNotification() && activePackage() == context.packageName
}
stage("screen-consent-$round-granted")
click("Close")
}
private fun assertDeviceFrame()
{
await("actual-device-frame") {
val image = SessionAcceptanceClient.image(admin.widget(sessionId))
val value = row()
image != null && value.optString("captureMode") == "DEVICE_SCREEN" && value.optBoolean("screenAvailable") && decoded(image) { bitmap ->
bitmap.width in 1..640 && bitmap.height in 1..1280 && nonblank(blocks(bitmap))
}
}
}
private fun row() = admin.sessions().single { it.getString("id") == sessionId }
private fun projectionNotification() = context.getSystemService(NotificationManager::class.java).activeNotifications.any {
it.notification.channelId == "qqq-device-screen"
}
private fun activityFocused(): Boolean
{
var focused = false
instrumentation.runOnMainSync { focused = ui.activity.hasWindowFocus() }
return focused
}
private fun activePackage(): String?
{
val root = instrumentation.uiAutomation.rootInActiveWindow ?: return null
return try { root.packageName?.toString() } finally { @Suppress("DEPRECATION") root.recycle() }
}
private fun signIn(password: String)
{
waitFor("Username")
ui.onNodeWithText("Username").performTextReplacement("scanner")
ui.onNodeWithText("Password").performTextReplacement(password)
ui.onNodeWithText("Password").performImeAction()
click("Sign in", scroll = true)
}
private fun click(text: String, scroll: Boolean = false)
{
val node = ui.onNode(hasText(text, substring = false) and hasClickAction())
if(scroll) node.performScrollTo()
node.performClick()
}
private fun waitFor(text: String) = await("app-control") { ui.onAllNodesWithText(text, substring = false).fetchSemanticsNodes().isNotEmpty() }
private fun stage(name: String) = instrumentation.sendStatus(0, Bundle().apply { putString("qqqStage", name) })
private fun await(name: String, seconds: Long = 40, predicate: () -> Boolean)
{
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds)
var previous = System.nanoTime()
do
{
val now = System.nanoTime()
ui.mainClock.advanceTimeBy(TimeUnit.NANOSECONDS.toMillis(now - previous).coerceAtLeast(16))
previous = now
if(predicate()) return
Thread.sleep(250)
} while(System.nanoTime() < deadline)
throw AssertionError("Device-screen stage did not complete within its bound: $name")
}
private fun decoded(image: String, predicate: (Bitmap) -> Boolean): Boolean
{
val bytes = Base64.decode(image.substringAfter(','), Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return false
return try { predicate(bitmap) } finally { bitmap.recycle() }
}
/** Display pixels are an independent in-memory oracle, never uploaded or written as an artifact. */
private fun matchesDisplay(image: String?): Boolean
{
if(image == null) return false
val before = instrumentation.uiAutomation.takeScreenshot() ?: return false
try
{
val first = blocks(before)
if(!nonblank(first)) return false
val agrees = decoded(image) { frame ->
frame.width in 1..640 && frame.height in 1..1280 &&
abs(frame.width.toDouble() / frame.height - before.width.toDouble() / before.height) < 0.01 &&
compatible(first, blocks(frame))
}
val after = instrumentation.uiAutomation.takeScreenshot() ?: return false
return try { agrees && error(first, blocks(after)).maxOrNull()!! <= 3.0 } finally { after.recycle() }
}
finally { before.recycle() }
}
// 24x40 spatial block means preserve launcher structure while averaging JPEG ringing and resize interpolation.
// Exclude outer10% (status/navigation/consent indicators); require mean RGB error<=12/255 and95%<=24/255.
// This deliberately fails changed layouts/blank screens; it is not a perceptual hash or exact-pixel claim.
private fun blocks(bitmap: Bitmap): DoubleArray
{
val result = DoubleArray(24 * 40 * 3)
for(y in 0 until 40) for(x in 0 until 24)
{
val left = (bitmap.width * (0.1 + 0.8 * x / 24)).toInt()
val right = (bitmap.width * (0.1 + 0.8 * (x + 1) / 24)).toInt().coerceAtLeast(left + 1)
val top = (bitmap.height * (0.1 + 0.8 * y / 40)).toInt()
val bottom = (bitmap.height * (0.1 + 0.8 * (y + 1) / 40)).toInt().coerceAtLeast(top + 1)
val i = (y * 24 + x) * 3
for(py in top until bottom) for(px in left until right)
{
val pixel = bitmap.getPixel(px, py)
result[i] = result[i] + Color.red(pixel).toDouble()
result[i + 1] = result[i + 1] + Color.green(pixel).toDouble()
result[i + 2] = result[i + 2] + Color.blue(pixel).toDouble()
}
val count = (right - left) * (bottom - top)
for(channel in 0..2) result[i + channel] = result[i + channel] / count.toDouble()
}
return result
}
private fun nonblank(values: DoubleArray): Boolean
{
// Spatial variation, not variation between RGB channels: a solid red frame must also fail.
return (0..2).any { channel ->
val samples = values.indices.filter { it % 3 == channel }.map { values[it] }
val average = samples.average()
sqrt(samples.map { (it - average) * (it - average) }.average()) >= 18.0 &&
samples.maxOrNull()!! - samples.minOrNull()!! >= 48.0
}
}
private fun error(first: DoubleArray, second: DoubleArray) = first.indices.map { abs(first[it] - second[it]) }.sorted()
private fun compatible(first: DoubleArray, second: DoubleArray): Boolean
{
val errors = error(first, second)
return nonblank(first) && nonblank(second) && errors.average() <= 12.0 && errors[(errors.size * 0.95).toInt()] <= 24.0
}
private fun comparatorControls()
{
val pattern = Bitmap.createBitmap(240, 400, Bitmap.Config.ARGB_8888)
val blank = Bitmap.createBitmap(240, 400, Bitmap.Config.ARGB_8888)
try
{
for(y in 0 until pattern.height) for(x in 0 until pattern.width)
pattern.setPixel(x, y, if((x / 24 + y / 40) % 2 == 0) Color.WHITE else Color.BLACK)
val reference = blocks(pattern)
assertTrue("Comparator accepts identical nonblank control", compatible(reference, reference))
assertFalse("Comparator rejects blank frame", compatible(reference, blocks(blank)))
assertFalse("Comparator rejects a wrong spatial layout", compatible(reference, reference.map { 255.0 - it }.toDoubleArray()))
}
finally { pattern.recycle(); blank.recycle() }
}
}
@@ -222,7 +222,7 @@ class ScannerInjectionTest
while(saved == null)
{
assertTrue("Timed out waiting for the operator's physical trigger", System.nanoTime() < deadline)
foreground(context)
foreground(context, remainingMillis().coerceAtMost(5000))
val current = pollScans("waiting for the operator's acknowledged scan")
val added = current.filter { it.getString("id") !in priorIds }
assertTrue("One trigger must not create multiple records", added.size <= 1 && current.size <= before.size + 1)
@@ -239,7 +239,7 @@ class ScannerInjectionTest
waitFor("Saved scan ${row.getString("id")}", timeoutMillis = remainingMillis())
waitFor("Scanner ready. Press the handheld trigger.", timeoutMillis = remainingMillis())
assertEquals(before.size + 1, pollScans("final saved-scan verification").size)
foreground(context)
foreground(context, remainingMillis().coerceAtMost(5000))
InstrumentationRegistry.getInstrumentation().sendStatus(0, Bundle().apply { putString("qqqStage", "hardware-$phase-saved") })
}
@@ -265,14 +265,27 @@ class ScannerInjectionTest
assertEquals(marker, result.get()?.getString("marker"))
}
private fun foreground(context: Context)
private fun foreground(context: Context, timeoutMillis: Long = 5000)
{
ui.activityRule.scenario.onActivity {
assertTrue("Target must stay resumed", it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
assertTrue("Target must retain window focus", it.window.decorView.hasWindowFocus())
assertTrue(context.getSystemService(PowerManager::class.java).isInteractive)
assertFalse(context.getSystemService(KeyguardManager::class.java).isKeyguardLocked)
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
var ready = false
var flags = "unavailable"
do
{
ui.activityRule.scenario.onActivity {
val resumed = it.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
val focused = it.window.decorView.hasWindowFocus()
val interactive = context.getSystemService(PowerManager::class.java).isInteractive
val locked = context.getSystemService(KeyguardManager::class.java).isKeyguardLocked
flags = "resumed=$resumed, focused=$focused, interactive=$interactive, locked=$locked"
ready = resumed && focused && interactive && !locked
}
if(ready) return
val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime())
if(remaining > 0) Thread.sleep(remaining.coerceAtMost(50))
}
while(System.nanoTime() < deadline)
assertTrue("Target must regain resumed, unlocked foreground focus: $flags", ready)
}
private fun scans(timeoutMillis: Long = 20_000): List<JSONObject>
+77 -13
View File
@@ -1,7 +1,7 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Real native host + isolated QQQ demo. Never touches the normal scanner package.
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { randomBytes, createHash } from 'node:crypto';
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
@@ -18,26 +18,36 @@ export async function runScannerE2e(options = {}) {
const serial = options.serial ?? process.env.ANDROID_SERIAL;
const selectedScenario = options.scenario ?? process.env.E2E_SCENARIO;
const physical = options.physical === true;
const deviceScreen = selectedScenario === 'device-screen';
if (deviceScreen && (!physical || options.operatorConsent !== true || !options.fixture || !serial || !/^[A-Za-z0-9._:-]+$/.test(serial))) {
throw new Error('Device-screen acceptance requires an explicit physical serial, private fixture and operator-consent opt-in.');
}
const fixture = options.fixture ? JSON.parse(await readFile(options.fixture, 'utf8')) : null;
if (fixture) {
const url = new URL(fixture.baseUrl);
if (!physical || selectedScenario !== 'photos' || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' || fixture.processName !== 'mobileRecordPhotos' || fixture.tableName !== 'document' || String(fixture.recordId) !== '1' ||
const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) :
selectedScenario === 'photos' && fixture.processName === 'mobileRecordPhotos' && fixture.tableName === 'document' && String(fixture.recordId) === '1';
if (!physical || !purposeValid || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
fixture.username !== 'scanner' || fixture.adminUsername !== 'admin' ||
typeof fixture.password !== 'string' || fixture.password.length < 16 || typeof fixture.adminPassword !== 'string' || fixture.adminPassword.length < 16) {
throw new Error('Photo acceptance requires the isolated loopback Record Files fixture and its private credential file.');
throw new Error('Physical acceptance requires its purpose-matched isolated loopback fixture and private credential file.');
}
}
if (selectedScenario === 'photos' && !fixture) throw new Error('The photos scenario requires an explicit isolated Record Files fixture.');
const adbPrefix = serial ? ['-s', serial] : [];
const packageName = 'io.qrun.mobile.scanner.e2e';
const physicalSession = physical && selectedScenario === 'session-monitor';
const packageName = deviceScreen || physicalSession ? 'io.qrun.mobile.scanner.qa' : 'io.qrun.mobile.scanner.e2e';
const variant = deviceScreen || physicalSession ? 'qa' : 'e2e';
const stages = new Set();
const adminPassword = fixture?.adminPassword ?? randomBytes(24).toString('base64url');
const scannerPassword = fixture?.password ?? randomBytes(24).toString('base64url');
const redact = (text) => text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>');
const run = (command, args, input) => new Promise((resolveRun, reject) => {
const child = spawn(command, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
// Native Compose instrumentation includes emulator startup and transport shutdown after the test finishes.
const timeout = args.includes('instrument') ? 300_000 : 180_000;
const deadline = setTimeout(() => { child.kill(); reject(new Error(`${command} exceeded the E2E timeout`)); }, timeout);
const timeout = args.includes('instrument') ? (deviceScreen ? 720_000 : 300_000) : 180_000;
const operation = command === adb ? args.slice(serial ? 2 : 0, (serial ? 2 : 0) + 3).join(' ') : basename(command);
const deadline = setTimeout(() => { child.kill(); reject(new Error(`E2E operation timed out: ${operation}`)); }, timeout);
let output = '';
let statusBuffer = '';
child.stdout.on('data', (data) => {
@@ -47,7 +57,10 @@ export async function runScannerE2e(options = {}) {
const lines = statusBuffer.split(/\r?\n/);
statusBuffer = lines.pop();
for (const line of lines) {
if (/^INSTRUMENTATION_STATUS: qqqStage=[A-Za-z0-9._ -]+$/.test(line)) console.log(line);
if (/^INSTRUMENTATION_STATUS: qqqStage=[A-Za-z0-9._ -]+$/.test(line)) {
stages.add(line.substring(line.indexOf('qqqStage=') + 9));
console.log(line);
}
}
}
});
@@ -93,7 +106,7 @@ export async function runScannerE2e(options = {}) {
}
console.log(`Native ${scenario} acceptance passed (bundled resources, no server).`);
};
const allowedScenarios = [...(physical ? ['navigation', 'photos'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
if (selectedScenario && !allowedScenarios.includes(selectedScenario)) throw new Error('Unknown native E2E scenario.');
if (Object.hasOwn(localScenarios, selectedScenario)) {
const temporary = [];
@@ -148,6 +161,8 @@ export async function runScannerE2e(options = {}) {
const animationScales = new Map();
const installedByRun = new Set();
let reverseAdded = false;
let screenEvidence;
let screenArtifacts;
server?.on('error', (error) => { serverError = error; });
try {
for (const setting of physical ? [] : ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
@@ -166,12 +181,50 @@ export async function runScannerE2e(options = {}) {
await new Promise((done) => setTimeout(done, 250));
}
proxy = await startSemanticApiProxy(origin);
const apkPaths = [join(repository, `scanner-host/build/outputs/apk/${variant}/scanner-host-${variant}.apk`),
join(repository, `scanner-host/build/outputs/apk/androidTest/${variant}/scanner-host-${variant}-androidTest.apk`)];
if (deviceScreen) {
if (!sdk) throw new Error('ANDROID_HOME is required for APK identity verification.');
const tool = join(sdk, 'build-tools', '35.0.0', process.platform === 'win32' ? 'aapt2.exe' : 'aapt2');
for (const [index, apk] of apkPaths.entries()) {
const actual = /^package: name='([^']+)'/m.exec(await run(tool, ['dump', 'badging', apk]))?.[1];
if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the disposable QA target.');
}
const version = await fetch(`${origin}/qqq/v1/version`, { signal: AbortSignal.timeout(10_000) }).then(response => {
if (!response.ok) throw new Error('Fixture discovery failed.'); return response.json();
});
const extension = version.extensions?.find(value => value.name === 'mobile-scanners');
if (extension?.version !== '1.2.0' || extension.pathPrefix !== '/qbit/mobile-scanners') throw new Error('Device-screen fixture must serve the qualified mobile-scanners1.2 contract.');
screenArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') })));
screenEvidence = join(repository, `scanner-host/build/native-device-screen-${Date.now()}`);
await mkdir(screenEvidence, { recursive: true });
await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PREPARING', artifacts: screenArtifacts }, null, 2));
}
await adbRun('reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`);
reverseAdded = true;
await adbRun('install', ...(physical ? [] : ['-r']), join(repository, 'scanner-host/build/outputs/apk/e2e/scanner-host-e2e.apk'));
await adbRun('install', ...(physical ? ['--no-streaming'] : ['-r']), apkPaths[0]);
installedByRun.add(packageName);
await adbRun('install', ...(physical ? [] : ['-r']), join(repository, 'scanner-host/build/outputs/apk/androidTest/e2e/scanner-host-e2e-androidTest.apk'));
await adbRun('install', ...(physical ? ['--no-streaming'] : ['-r']), apkPaths[1]);
installedByRun.add(packageName + '.test');
if (deviceScreen) {
await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'files');
await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > files/qa-screen.json'"],
JSON.stringify({ purpose: 'device-screen-qa', serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword }));
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r', '-e', 'operatorConsent', 'true',
'-e', 'class', 'io.qrun.mobile.scanner.DeviceScreenAcceptanceTest', `${packageName}.test/androidx.test.runner.AndroidJUnitRunner`);
await writeFile(join(screenEvidence, 'instrumentation.txt'), output);
const expectedStages = ['screen-app-frame', 'screen-home-verified', 'screen-stop-verified', 'screen-expiry-verified', 'screen-logout-verified', 'screen-fresh-login-verified'];
if (!/OK \(1 test\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output) || expectedStages.some(stage => !stages.has(stage))) {
throw new Error('Manual device-screen acceptance did not complete every required stage; inspect its private evidence.');
}
const semantic = proxy.verify(true, false, true);
const uploads = proxy.evidence().filter(call => call.android && call.operationId === 'mobileScannersUploadSessionScreen' && call.status === 204).length;
if (uploads < 4) throw new Error('Insufficient successful native frame uploads for the physical display stages.');
await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({ status: 'PASSED', artifacts: screenArtifacts, semantic,
completedStages: expectedStages, operatorConsentRounds: 3, homeDisplayComparedInMemory: true, successfulNativeFrameUploads: uploads }, null, 2));
console.log('Operator-approved QA device-screen acceptance passed. No screen pixels were retained in artifacts.');
return;
}
const evidence = join(repository, physical ? 'scanner-host/build/native-device-e2e' : 'scanner-host/build/native-e2e');
await mkdir(evidence, { recursive: true });
const reports = [];
@@ -218,15 +271,25 @@ export async function runScannerE2e(options = {}) {
}
await writeFile(join(evidence, 'semantic-contract.json'), `${JSON.stringify(reports, null, 2)}\n`);
console.log('All native requests in the selected scenarios matched the published semantic API.');
} catch (error) {
if (deviceScreen && screenEvidence) await writeFile(join(screenEvidence, 'proof.json'), JSON.stringify({
status: 'FAILED', artifacts: screenArtifacts, completedStages: [...stages], physicalDisplayVerified: false,
}, null, 2));
throw error;
} finally {
const screenCleanupFailures = [];
if (deviceScreen && screenEvidence && proxy) await writeFile(join(screenEvidence, 'operations.json'), JSON.stringify(proxy.evidence(), null, 2))
.catch(() => { screenCleanupFailures.push('semantic evidence'); });
for (const [setting, previous] of animationScales) {
if (previous === 'null') await adbRun('shell', 'settings', 'delete', 'global', setting).catch(() => {});
else await adbRun('shell', 'settings', 'put', 'global', setting, previous).catch(() => {});
}
if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => {});
for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => {});
if (deviceScreen && installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/qa-screen.json')
.catch(() => { screenCleanupFailures.push('QA credential file'); });
for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen) screenCleanupFailures.push('QA package'); });
if (proxy) {
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => {});
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen) screenCleanupFailures.push('owned reverse'); });
await proxy.close();
}
const stopped = new Promise((done) => { if (!server || server.exitCode !== null || serverError) done(); else server.once('exit', done); });
@@ -239,6 +302,7 @@ export async function runScannerE2e(options = {}) {
throw new Error('Refusing cleanup outside the isolated demo temporary directory.');
}
if (directory) await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (screenCleanupFailures.length) throw new Error(`QA cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`);
}
}
+2 -2
View File
@@ -41,7 +41,7 @@ const run = (args, input) => new Promise((done, fail) => {
const child = spawn(adb, ['-s', serial, ...args], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let lines = '';
const timer = setTimeout(() => { child.kill(); fail(new Error('Bounded QA adb operation timed out.')); }, args.includes('instrument') ? (physicalTriggers ? 600_000 : 300_000) : 60_000);
const timer = setTimeout(() => { child.kill(); fail(new Error(`Bounded QA adb operation timed out: ${args.slice(0, 3).join(' ')}`)); }, args.includes('instrument') ? (physicalTriggers ? 600_000 : 300_000) : 60_000);
child.stdout.on('data', data => {
output += data;
lines += data;
@@ -110,7 +110,7 @@ try {
proxy = await startSemanticApiProxy(origin.toString().replace(/\/$/, ''));
await run(['reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`]);
reversed = true;
for (const [applicationId, apk] of apks) { await run(['install', apk]); installedByRun.push(applicationId); }
for (const [applicationId, apk] of apks) { await run(['install', '--no-streaming', apk]); installedByRun.push(applicationId); }
await run(['shell', 'run-as', target, 'mkdir', '-p', 'files']);
await run(['shell', 'run-as', target, 'sh', '-c', "'cat > files/qa-injection.json'"],
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, scannerPassword: config.scannerPassword, adminPassword: config.adminPassword,
+8
View File
@@ -0,0 +1,8 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Manual-only system-consent acceptance. Never part of hosted/emulator defaults.
import { runScannerE2e } from './scanner-e2e-runner.mjs';
const args = process.argv.slice(2);
if (args.length !== 5 || args[0] !== '--serial' || args[2] !== '--fixture' || args[4] !== '--operator-consent' || !args[1] || !args[3]) {
throw new Error('Usage: node scripts/ci/scanner-screen-device.mjs --serial TC22_SERIAL --fixture PRIVATE_QA_FIXTURE.json --operator-consent');
}
await runScannerE2e({ physical: true, serial: args[1], scenario: 'device-screen', fixture: args[3], operatorConsent: true });