Merge retained GitHub work into the Gitea primary branch #9

Merged
20 changed files with 684 additions and 30 deletions

No files matched your search

+2
View File
@@ -37,6 +37,8 @@ jobs:
cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Verify source headers, whitespace, imports and JavaScript syntax
run: node scripts/ci/check-source-standards.mjs
- name: Run no-device Echo fixture guard tests
run: node --test scripts/ci/scanner-echo-guards.test.mjs
- name: Install required Android SDK packages
run: |
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;34.0.0" "build-tools;35.0.0" "platform-tools"
+43
View File
@@ -0,0 +1,43 @@
# Claude native handoff: Echo setup named possible values
## Source-verified contract (core `qqq-wt-native-provider-logout`)
- operationId `searchProcessPossibleValues`: `POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}` (`UiApiOperationIds`, `ProcessPossibleValuesSpecV1`, openapi.yaml ~12440). Server advertises feature `possibleValues` on `GET /version`.
- Request: `application/json` `{ "values": {other form values}, "processValues": {...}, "useCase": "form", "searchTerm"?, "ids"? }` (`PossibleValuesSpecUtils`). Not multipart. Same session cookie, `X-QQQ-Client` and device headers as other native calls (shared Retrofit client).
- Response `PossibleValuesResponseV1`: `{ "options": [{ "id", "label", ... }], "hasMore"? }`; `options` is omitted when empty.
- Field metadata (FieldMetaData v1): `possibleValueSourceName`, and for contingent fields `possibleValueSourceDependsOn: [names]` and `possibleValueSourceDependencyBehavior: DISABLE_UNTIL_SET | SHOW_ALL_UNTIL_SET` (docs/CONTINGENT-POSSIBLE-VALUES.md).
- Frontend rules (same doc): re-query when a dependency changes, clear a selection that no longer fits (transitively), disable with a hint until dependencies are set (default), ignore stale responses.
A previous revision of this work invented the multipart shape from memory; it has been replaced entirely by the above. The route path happened to match; the body did not.
## Root cause (unchanged, source inference)
Echo `setup` declares `printerId`/`mediaId` with named PVS (media `dependsOn printerId`); fixture uses ENUM PVS. The client rendered only inline choices, so Printer became a free-text "Printer *" field and submission was blocked as unsupported. That init is a synchronous COMPLETE (not ASYNC) is inferred from sources and the operations log; the native E2E remains the authority. The fixture is NOT changed to inline choices.
## Implementation
- core: `QQQApiService.processPossibleValues`; `QQQRepository.processPossibleValues(process, field, values, processValues)` (default throws Unsupported so other fakes compile); `NetworkQQQRepository` builds the JSON body and decodes options (scalar ids kept); `QFieldMetaData` gains `possibleValueSourceDependsOn` / `possibleValueSourceDependencyBehavior`.
- mobileapp `ProcessViewModel`: `loadRemoteOptions`, `remoteOptionsBlockedReason` (DISABLE_UNTIL_SET -> "Choose Printer first." and no request), `fieldChanged` (dependents drop selection immediately, transitively, and re-query), per-field generation token so only the newest request publishes (a late reply for an old parent cannot restore options or a selection), post-load prune of a choice no longer offered, `fieldResets` so the control follows a VM-cleared value. Load failures are shown with "Retry choices", cancel the superseded job, never set client-work, so Cancel/Leave stay available. `reset()` clears all of it.
- `ProcessComponentEditForm`: select for named PVS (label text unchanged: "Printer", "Paper or label size", button "Choose a value"), disabled with a hint while blocked.
- `SemanticProcess.unsupported` accepts named PVS in EDIT_FORM only (widget INPUT_FIELD remains unsupported).
- E2E test: literal `waitFor("Printer")` unchanged (diagnostic text dump retained on timeout); added a readiness wait for the enabled media control before clicking it, since media is contingent. No assertion weakened.
- docs/UI-API.md updated.
## Tests added/updated
- `NetworkQQQRepositoryTest.processPossibleValuesUsesThePublishedJsonOperation`: method/path/JSON body/use case, response with options, and the omitted-`options` empty response.
- `ProcessSubmissionTest.namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures`: blocked dependent not searched; parent value sent; dependent selection cleared on parent change; late old-parent reply ignored; failure reported, no client-work block, retry succeeds.
- `SemanticProcessTest`: named PVS in EDIT_FORM supported; UNKNOWN type and widget PVS still unsupported.
Note: server response bodies in tests are minimal shapes taken from `PossibleValuesResponseV1`/`ProcessPossibleValuesSpecV1Test` (`options[].id/label`), not captured from a live server.
## Commands for root (Java 21, wrapper)
```
./gradlew :qqq:android:core:testDebugUnitTest :qqq:android:mobileapp:testDebugUnitTest :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
node --test scripts/ci/scanner-echo-guards.test.mjs
```
Then re-approve rebuilt APK hashes and run the root native Echo helper.
## Known gaps / risks
- Nothing compiled or run (no shell). Expect possible small compile fixes (Compose state/`mutableIntStateOf` delegate, imports).
- `possibleValues` is not added to the client's REQUIRED_FEATURES (all existing fixtures advertise only six features); a server lacking it fails the search with a visible error + Retry rather than at startup.
- Pre-existing and untested: decoding inline `inlinePossibleValueSource` ids typed `java.io.Serializable` via kotlinx.
- Whether the real Echo metadata publishes `possibleValueSourceDependsOn` for media is inferred from `withPossibleValueSourceDependsOn(List.of("printerId"))` in the producer; unverified against a live `/metaData/process/echoPrint` response.
- Untracked `native.md` at the repo root is an obsolete earlier handoff (a pointer to this file); delete it.
Coordinator verification:169JVMtests pass; lint0errors/0warnings; E2EAPKs assemble; all8guards/sourcegate236pass. RealTC22 Echo native scenario completes all5stages with86/86documented semanticrequests,2accepted synthetic scans andsuccessfulcleanup. Printing remains simulated.
+42
View File
@@ -0,0 +1,42 @@
# Echo native UI acceptance fixture
The explicit Echo scenario exercises the real Android host, TABLE login, enrollment/approval, semantic printer/media choices, real Echo process and H2 persistence. Its test-only backend accepts **simulated** jobs. Passing it does not qualify EMDK, barcode rendering, printer adapters or physical output.
The backend owner supplies `com.kingsrook.examples.echo.fixture.EchoHttpFixture` from the isolated optional Echo test module, launched with a free loopback port, `ECHO_FIXTURE_TYPE=echo-ui-e2e` and private `ECHO_FIXTURE_SCANNER_PASSWORD`/`ECHO_FIXTURE_ADMIN_PASSWORD`. Readiness is `ECHO_UI_E2E_READY port=N simulatedPrinting=true`. It uses in-memory H2 and no real printing/generation adapter. Root owns launch, the exact private dependency tuple and approval of the concrete APK/fixture cohort.
The credential file must stay private and contain these fields with newly generated passwords of at least 16 characters:
```json
{
"fixtureType": "echo-ui-e2e",
"simulatedPrinting": true,
"baseUrl": "http://127.0.0.1:PRIVATE_FREE_PORT/",
"username": "scanner",
"password": "PRIVATE_GENERATED_PASSWORD",
"adminUsername": "admin",
"adminPassword": "PRIVATE_GENERATED_PASSWORD"
}
```
The runner refuses normal scanner ports 57832/51631 and printing ports 18081/18086, non-loopback/decorated URLs, the wrong purpose, unsimulated fixtures, incompatible core/mobile contracts and branding other than `Echo UI E2E — simulated printing`. It requires UI API1.10.0/mobile-scanners1.4.0 and checks APK package identities. Only a selected physical Zebra TC22 and absent disposable `io.qrun.mobile.scanner.e2e`/`.test` packages are accepted. Normal and QA packages/data remain untouched. Location reporting is disabled only in the new disposable package, and no screenshots are taken.
Preparation requires no device execution:
```text
node --test scripts/ci/scanner-echo-guards.test.mjs
./gradlew :scanner-host:testDebugUnitTest :scanner-host:lintDebug :scanner-host:assembleE2e :scanner-host:assembleE2eAndroidTest
```
Only after root launches the private fixture and approves the exact cohort:
```text
node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json
```
The native test signs in, enrolls and approves through existing helpers; opens Apps → Echo; selects `Simulated printer` / `Simulated 4 x 6 in — 300 dpi` once through the semantic possible-value controls; injects two public synthetic values with CODE128/QR types through the E2E-only driver; checks `Print queued` and absent manual controls; rotates/recreates without another acceptance; then uses Apps/Leave and verifies the run closes. Independent reads use standard QQQ `echoScan` and read-only `echoFixtureRun` queries as the scanner with approved-device headers, retaining the real actor/device locks. No custom business verification endpoint or administrator bypass is used.
The existing OpenAPI proxy audits all observed requests and requires native cancellation. Fixed stage markers and operation IDs/counts, APK hashes and a simulation-labelled `echo-proof.json` are written under `scanner-host/build/native-device-e2e/`; credentials, serial and opaque identifiers are redacted from instrumentation output. No normal app data, global animation settings, DataWedge profile or existing forwarding is replaced. The runner removes only its own reverse and newly installed disposable packages; cleanup failures fail the run and its proof.
At preparation time, compilation and source/unit guards are the only authorized checks. Native/device acceptance remains pending until the explicit approved invocation completes; do not describe this fixture as physical printing evidence.
Recorded acceptance: all5stages passed onTC22 with169JVMtests,8Nodeguards,zero-warninglint and86/86documentednative requests. Printing is simulated. The proof explicitly leaves physicalScannerQualified and physicalPrintingQualified false.
+5
View File
@@ -1,3 +1,8 @@
## November 1 — Echo native semantic choices verified on TC22
Claude implemented named process possible values using the actual published `searchProcessPossibleValues` JSON contract, dependent-field metadata, cancellation/error/retry and stale-response rejection. The unchanged Echo fixture now completes login, enrollment/approval, printer/media selection once, two accepted synthetic scans, rotation/recreation without duplicates, and Apps/Leave closing the run on the real TC22. All86observed requests matched published semantic operations. Disposable-package cleanup passed; normal package/data and real printers were preserved.
Validation:169JVMtests,8Nodeguards,236-file source standards, zero lint errors/warnings and bothE2EAPKs pass. This is simulated printing, not EMDK or physical output qualification. The remaining immediate blocker is the real Generation/Printing adapter and template/host composition, followed by physical print/rescan. Normal installed APK remains625a275; normal server API1.9/mobile1.1. Production signing remains deferred.
# Session state
## Echo semantic presentation source checkpoint
+3
View File
@@ -91,3 +91,6 @@ Offline queue/background synchronization is deliberately outside version one.
- [x] Add and run real-server scanner-host E2E: login, pending enrollment, approval, native app/process selection, manual scan persistence and revoked-device denial. Use an isolated app ID and database.
- [x] Run and verify native real-server E2E on pushes and pull requests; current7de hosted checks pass.
- [ ] Complete the final operator walkthrough on TC22 after updating the preserved demo/runtime, including outage/reconnect and real camera capture.
- [x] Implement named semantic process choices and qualify Echo native setup/two scans/rotation/Apps Leave onTC22 (169JVM,8guards,86documentedrequests; printing simulated).
- [ ] Finish real Echo host/owner adapters, print a physical label and rescan exact value/type; then preserved-data demo upgrade.
+1 -1
View File
@@ -46,7 +46,7 @@ Core structured errors retain their code, message, field errors and trace ID. Fi
| --- | --- |
| App navigation | Server-defined apps and process links, including root processes and section app links |
| Process lifecycle | Init, step submission, asynchronous polling, server-directed completion and backend cancellation |
| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values and validation errors; explicit null clearing is preserved |
| `EDIT_FORM` | Metadata field subsets, labels, hidden/editable/required flags, text/password, Boolean, integer/long/decimal, inline possible values, named possible-value sources searched through the core operation `searchProcessPossibleValues` (`POST /qqq/v1/processes/{process}/possibleValues/{field}`, JSON `values`/`processValues`/`useCase: form`; contingent fields honor `possibleValueSourceDependsOn` and `possibleValueSourceDependencyBehavior`, re-query on parent change and drop stale selections) and validation errors; explicit null clearing is preserved |
| Date/time fields | ISO text inputs with format hints and validation; no native date/time picker |
| `VIEW_FORM` | Read-only values for the declared field subset |
| Dynamic metadata | Updated frontend steps and updated fields applied to the active process |
@@ -41,6 +41,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import retrofit2.Retrofit
import java.util.TimeZone
@@ -291,6 +292,24 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
qqqApiServiceMap[baseUrl]!!.processCancel(processName, processUUID).close()
}
/*******************************************************************************
** Options use the semantic `{options:[{id,label}]}` response; ids stay JSON scalars so they round-trip unchanged.
*******************************************************************************/
override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> {
fun json(map: Map<String, Any?>) = JSONObject().apply { map.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) } }
val request = JSONObject().put("values", json(values)).put("processValues", json(processValues)).put("useCase", "form")
val body = qqqApiServiceMap[baseUrl]!!.processPossibleValues(
processName, fieldName, request.toString().toRequestBody("application/json".toMediaType())
).use { JSONObject(it.string()) }
// The server omits `options` when empty.
val options = body.optJSONArray("options") ?: return emptyList()
return (0 until options.length()).map { options.getJSONObject(it) }.map { option ->
val id = option.get("id")
com.kingsrook.qqq.frontend.android.core.model.QPossibleValue(
id as? java.io.Serializable ?: id.toString(), option.optString("label", id.toString()))
}
}
/*******************************************************************************
** Poll the exact process/job identity supplied by the server instead of assuming background completion.
*******************************************************************************/
@@ -164,6 +164,16 @@ interface QQQApiService {
@Path(value = "jobUUID") jobUUID: String,
): ProcessStepResult
/*******************************************************************************
** Published operation `searchProcessPossibleValues`: JSON body {values, processValues, useCase, searchTerm, ids}.
*******************************************************************************/
@POST(BASE_PATH + "processes/{processName}/possibleValues/{fieldName}")
suspend fun processPossibleValues(
@Path(value = "processName") processName: String,
@Path(value = "fieldName") fieldName: String,
@Body body: RequestBody
): ResponseBody
/*******************************************************************************
** Cancel the identified server process explicitly instead of dropping only client navigation.
*******************************************************************************/
@@ -123,6 +123,13 @@ interface QQQRepository {
throw UnsupportedOperationException("Process cancellation is unavailable")
}
/*******************************************************************************
** Search a process field's server possible-value source (`searchProcessPossibleValues`) with the current form values
** (`values`, used for dependsOn/filter inputs) and scalar process values, using the FORM use case.
*******************************************************************************/
suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?> = emptyMap()): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> =
throw UnsupportedOperationException("Remote possible values are unavailable")
/*******************************************************************************
** Poll the server-issued job within its process instance instead of inferring asynchronous completion.
*******************************************************************************/
@@ -39,6 +39,9 @@ data class QFieldMetaData(
val isHeavy: Boolean = false,
val possibleValueSourceName: String? = null,
val inlinePossibleValueSource: List<QPossibleValue>? = null,
// Contingent fields only: DISABLE_UNTIL_SET (default when depending) or SHOW_ALL_UNTIL_SET.
val possibleValueSourceDependsOn: List<String>? = null,
val possibleValueSourceDependencyBehavior: String? = null,
val displayFormat: String? = null,
val adornments: List<FieldAdornment>? = null,
val helpContents: List<QHelpContent>? = null,
@@ -40,6 +40,36 @@ class NetworkQQQRepositoryTest {
}
}
/*******************************************************************************
** Published `searchProcessPossibleValues`: JSON body with the parent values and FORM use case; the response is
** `{options:[{id,label}]}` and the server omits `options` entirely when nothing matches.
*******************************************************************************/
@Test
fun processPossibleValuesUsesThePublishedJsonOperation() = runBlocking()
{
MockWebServer().use { server ->
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"options":[{"id":"4x6","label":"4 x 6 in"},{"id":7,"label":"Seven"}]}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
val origin = server.url("/").toString()
val repository = NetworkQQQRepository(origin)
repository.setEnvironment(Environment("test", origin))
val options = repository.processPossibleValues("echoPrint", "mediaId", mapOf("printerId" to "printer"), mapOf("echoRunId" to "run"))
assertEquals("/qqq/v1/version", server.takeRequest().path)
val request = server.takeRequest()
assertEquals("POST", request.method)
assertEquals("/qqq/v1/processes/echoPrint/possibleValues/mediaId", request.path)
assertTrue(request.getHeader("Content-Type")!!.startsWith("application/json"))
val body = org.json.JSONObject(request.body.readUtf8())
assertEquals("printer", body.getJSONObject("values").getString("printerId"))
assertEquals("run", body.getJSONObject("processValues").getString("echoRunId"))
assertEquals("form", body.getString("useCase"))
assertEquals(listOf("4x6", "7"), options.map { it.id.toString() })
assertEquals(listOf("4 x 6 in", "Seven"), options.map { it.label })
assertTrue(repository.processPossibleValues("echoPrint", "mediaId", emptyMap(), emptyMap()).isEmpty())
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -78,17 +78,25 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
fun change(newValue: Any?) {
value = newValue
processViewModel.processValues[field.name] = newValue
// Dependent possible-value sources (for example media for a printer) re-query, disable or drop their selection.
processViewModel.fieldChanged(field.name)
}
val remote = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty()
val blocked = processViewModel.remoteOptionsBlockedReason(field)
val reset = processViewModel.fieldResets[field.name]
LaunchedEffect(processViewModel.stepInstanceCounter, field.name) {
if (!processViewModel.processValues.containsKey(field.name)) processViewModel.processValues[field.name] = value
if (field.isEditable) processViewModel.registerFormFieldName(field.name)
if (remote) processViewModel.loadRemoteOptions(field)
}
// The view model cleared this selection because its controlling field changed or the choice no longer exists.
LaunchedEffect(reset) { if (reset != null) value = processViewModel.processValues[field.name] }
Column(Modifier.fillMaxWidth().padding(8.dp)) {
val options = field.inlinePossibleValueSource
if (!options.isNullOrEmpty()) {
val options = if (remote) processViewModel.remoteOptions[field.name].orEmpty() else field.inlinePossibleValueSource.orEmpty()
if (remote || options.isNotEmpty()) {
var expanded by remember { mutableStateOf(false) }
Text(field.label)
OutlinedButton(onClick = { expanded = true }, enabled = enabled) {
OutlinedButton(onClick = { expanded = true }, enabled = enabled && blocked == null) {
Text(options.firstOrNull { it.id.toString() == value?.toString() }?.label ?: "Choose a value")
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -114,6 +122,11 @@ fun ProcessComponentEditForm(processViewModel: ProcessViewModel, component: QFro
)
}
if (error != null) Text(error, color = MaterialTheme.colorScheme.error)
blocked?.let { Text(it) }
processViewModel.remoteOptionErrors[field.name]?.let { message ->
Text(message, color = MaterialTheme.colorScheme.error)
OutlinedButton(onClick = { processViewModel.loadRemoteOptions(field) }) { Text("Retry choices") }
}
semanticFieldHint(field)?.let { Text(it) }
field.helpContents.orEmpty().forEach { Text(it.contentAsText ?: plainText(it.content)) }
}
@@ -34,6 +34,8 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.QPossibleValue
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFieldMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QFrontendStepMetaData
import com.kingsrook.qqq.frontend.android.core.model.metadata.QInstance
import com.kingsrook.qqq.frontend.android.core.model.metadata.QProcessMetaData
@@ -69,6 +71,86 @@ open class ProcessViewModel : ViewModel() {
private set
private var processComplete: Boolean by mutableStateOf(false)
private var initializing = false
/** Options from server possible-value sources (`searchProcessPossibleValues`), keyed by field name. */
val remoteOptions = mutableStateMapOf<String, List<QPossibleValue>>()
val remoteOptionErrors = mutableStateMapOf<String, String>()
/** Incremented when this model clears a field's value, so the rendered control can follow. */
val fieldResets = mutableStateMapOf<String, Int>()
private var dependencyVersion by mutableIntStateOf(0)
private val remoteOptionJobs = mutableMapOf<String, Job>()
private val remoteOptionGenerations = mutableMapOf<String, Int>()
/*******************************************************************************
** Fields whose choices come from a named possible-value source rather than inline metadata.
*******************************************************************************/
private fun isRemote(field: QFieldMetaData) = field.possibleValueSourceName != null && field.inlinePossibleValueSource.isNullOrEmpty()
/*******************************************************************************
** DISABLE_UNTIL_SET (the default for contingent fields) offers no choices until every declared parent has a value.
*******************************************************************************/
fun remoteOptionsBlockedReason(field: QFieldMetaData): String? {
dependencyVersion // process values are not observable state; this makes dependent controls recompose
if (!isRemote(field) || field.possibleValueSourceDependencyBehavior == "SHOW_ALL_UNTIL_SET") return null
val missing = field.possibleValueSourceDependsOn.orEmpty().filter { processValues[it].let { value -> value == null || value.toString().isEmpty() } }
if (missing.isEmpty()) return null
val labels = missing.map { name -> activeStep?.formFields?.firstOrNull { it.name == name }?.label ?: name }
return "Choose ${labels.joinToString(" and ")} first."
}
/*******************************************************************************
** Search the field's server source with the current form values. Only the newest request may publish, so a late
** response for a replaced parent value can never restore choices (or a selection) that no longer apply.
*******************************************************************************/
fun loadRemoteOptions(field: QFieldMetaData) {
if (!isRemote(field)) return
val generation = (remoteOptionGenerations[field.name] ?: 0) + 1
remoteOptionGenerations[field.name] = generation
remoteOptionJobs.remove(field.name)?.cancel()
remoteOptionErrors.remove(field.name)
remoteOptions.remove(field.name)
if (remoteOptionsBlockedReason(field) != null) return
val formNames = activeStep?.formFields.orEmpty().map { it.name }.toSet()
val values = formNames.filter { it != field.name }.associateWith { processValues[it] }
val scalars = processValues.filter { (key, value) -> key !in formNames && (value is String || value is Number || value is Boolean) }
remoteOptionJobs[field.name] = viewModelScope.launch {
try {
val options = qqqRepository.processPossibleValues(processName, field.name, values, scalars)
if (remoteOptionGenerations[field.name] != generation) return@launch
remoteOptions[field.name] = options
val chosen = processValues[field.name]
if (chosen != null && options.none { it.id.toString() == chosen.toString() }) clearSelection(field.name)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (remoteOptionGenerations[field.name] != generation) return@launch
remoteOptionErrors[field.name] = semanticError(e, "The available choices could not be loaded. Try again.")
}
}
}
/*******************************************************************************
** After a field changes, dependents re-query (or disable) and drop selections that belonged to the old parent value.
*******************************************************************************/
fun fieldChanged(name: String) {
dependencyVersion++
for (dependent in activeStep?.formFields.orEmpty().filter { isRemote(it) && name in it.possibleValueSourceDependsOn.orEmpty() }) {
if (processValues[dependent.name] != null) clearSelection(dependent.name)
loadRemoteOptions(dependent)
}
}
/*******************************************************************************
** Clear a selection that no longer fits, then apply the same rule to fields depending on it.
*******************************************************************************/
private fun clearSelection(name: String) {
processValues[name] = null
fieldResets[name] = (fieldResets[name] ?: 0) + 1
fieldChanged(name)
}
private val clientWork = mutableStateMapOf<String, String>()
val clientWorkBlockReason: String? get() = clientWork.values.firstOrNull()
@@ -248,6 +330,11 @@ open class ProcessViewModel : ViewModel() {
*******************************************************************************/
fun reset() {
viewModelScope.coroutineContext.cancelChildren()
remoteOptionJobs.clear()
remoteOptionGenerations.clear()
fieldResets.clear()
remoteOptions.clear()
remoteOptionErrors.clear()
initializing = false
unconfirmedSubmission = false
clientWork.clear()
@@ -58,8 +58,8 @@ internal object SemanticProcess {
QComponentType.HELP_TEXT, QComponentType.HTML, QComponentType.PROCESS_SUMMARY_RESULTS, QComponentType.VIEW_FORM -> Unit
QComponentType.EDIT_FORM -> {
val unsupportedField = fields(step, component).firstOrNull {
it.type.name in setOf("BLOB", "UNKNOWN") ||
(it.possibleValueSourceName != null && it.inlinePossibleValueSource == null)
// Named possible-value sources are resolved through the semantic possibleValues operation.
it.type.name in setOf("BLOB", "UNKNOWN")
}
if (unsupportedField != null) return "${unsupportedField.label} requires an input control unavailable in this client."
}
@@ -525,6 +525,78 @@ class ProcessSubmissionTest {
}
/*******************************************************************************
** Echo-style setup: named possible-value sources are resolved through the repository, and dependent
** choices are requested with the current parent value rather than being rendered as free text.
*******************************************************************************/
@Test
fun namedPossibleValueSourcesLoadWithDependentValuesAndReportFailures() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
// As published by the server: media declares its explicit dependency on printerId (default DISABLE_UNTIL_SET).
val printer = QFieldMetaData("printerId", "Printer", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "printers")
val media = QFieldMetaData(
"mediaId", "Paper or label size", type = QFieldType.STRING, isRequired = true, possibleValueSourceName = "media",
possibleValueSourceDependsOn = listOf("printerId"), possibleValueSourceDependencyBehavior = "DISABLE_UNTIL_SET"
)
val repository = SubmissionRepository().apply {
inputOverride = QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)), formFields = listOf(printer, media))
}
val model = model(repository)
model.doInit()
advanceUntilIdle()
assertNull("Remote choices are supported, not an unsupported control", model.unsupportedReason())
model.loadRemoteOptions(printer)
model.loadRemoteOptions(media)
advanceUntilIdle()
assertEquals(listOf("Simulated printer"), model.remoteOptions["printerId"]?.map { it.label })
assertEquals("Choose Printer first.", model.remoteOptionsBlockedReason(media))
assertEquals("A blocked dependent field is not searched", listOf("printerId"), repository.possibleValueRequests.map { it.first })
model.processValues["printerId"] = "printer"
model.fieldChanged("printerId")
advanceUntilIdle()
assertNull(model.remoteOptionsBlockedReason(media))
assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label })
assertEquals("printer", repository.possibleValueRequests.last().second["printerId"])
assertFalse("The requested field's own value is not sent as a dependency", repository.possibleValueRequests.last().second.containsKey("mediaId"))
// Changing the parent drops the dependent selection immediately, and a late reply for the old parent is ignored.
model.processValues["mediaId"] = "4x6"
val gate = CompletableDeferred<Unit>()
repository.possibleValueGate = gate
model.loadRemoteOptions(media)
runCurrent()
model.processValues["printerId"] = "other"
model.fieldChanged("printerId")
assertNull("Stale child selection is cleared when its parent changes", model.processValues["mediaId"])
assertEquals(1, model.fieldResets["mediaId"])
assertNull(model.remoteOptions["mediaId"])
repository.possibleValueGate = null
gate.complete(Unit)
advanceUntilIdle()
assertEquals("Only the newest request may publish", emptyList<String>(), model.remoteOptions["mediaId"]?.map { it.label })
// A failed load is reported without blocking Cancel, and can be retried.
repository.possibleValueFailure = true
model.loadRemoteOptions(media)
advanceUntilIdle()
assertNull(model.remoteOptions["mediaId"])
assertNotNull(model.remoteOptionErrors["mediaId"])
assertNull(model.clientWorkBlockReason)
repository.possibleValueFailure = false
model.processValues["printerId"] = "printer"
model.loadRemoteOptions(media)
advanceUntilIdle()
assertNull(model.remoteOptionErrors["mediaId"])
assertEquals(listOf("4 x 6 in"), model.remoteOptions["mediaId"]?.map { it.label })
} finally {
Dispatchers.resetMain()
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -554,6 +626,9 @@ class ProcessSubmissionTest {
var statusFailures = 0
var statusCalls = 0
var inputOverride: QFrontendStepMetaData? = null
val possibleValueRequests = mutableListOf<Pair<String, Map<String, Any?>>>()
var possibleValueFailure = false
var possibleValueGate: CompletableDeferred<Unit>? = null
private val input = QFrontendStepMetaData(
"input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)),
formFields = listOf(QFieldMetaData("eventId", "Event", type = QFieldType.STRING, isRequired = true, isHidden = hiddenEventId))
@@ -587,6 +662,20 @@ class ProcessSubmissionTest {
return if (asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep)
}
/*******************************************************************************
** Printer choices are independent; media choices depend on the supplied printer.
*******************************************************************************/
override suspend fun processPossibleValues(processName: String, fieldName: String, values: Map<String, Any?>, processValues: Map<String, Any?>): List<com.kingsrook.qqq.frontend.android.core.model.QPossibleValue> {
possibleValueRequests.add(fieldName to values.toMap())
possibleValueGate?.await()
if (possibleValueFailure) throw java.io.IOException("Choices unavailable")
return when {
fieldName == "printerId" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("printer", "Simulated printer"))
values["printerId"] == "printer" -> listOf(com.kingsrook.qqq.frontend.android.core.model.QPossibleValue("4x6", "4 x 6 in"))
else -> emptyList()
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -230,9 +230,10 @@ class SemanticProcessTest {
**
*******************************************************************************/
@Test
fun remotePossibleValuesAndUnknownInputTypesAreExplicitlyUnavailable() {
for (unsupportedField in listOf(field.copy(possibleValueSourceName = "remote"), field.copy(type = QFieldType.UNKNOWN)))
assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(unsupportedField)), emptyMap(), null, emptySet()))
fun unknownInputTypesAreExplicitlyUnavailableButNamedPossibleValueSourcesAreRendered() {
assertNotNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(type = QFieldType.UNKNOWN))), emptyMap(), null, emptySet()))
// Printer/media style fields load their choices through the semantic possibleValues operation.
assertNull(SemanticProcess.unsupported(step.copy(formFields = listOf(field.copy(possibleValueSourceName = "remote"))), emptyMap(), null, emptySet()))
}
/*******************************************************************************
@@ -21,6 +21,7 @@ import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
@@ -36,6 +37,7 @@ import androidx.core.content.FileProvider
import java.io.File
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.net.URI
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
@@ -60,6 +62,119 @@ class ScannerEndToEndTest {
private lateinit var origin: String
private lateinit var adminCookie: String
/*******************************************************************************
** Exercise real Echo process/PVS persistence with explicitly simulated print acceptance.
** No printer, production scanner ingress, screenshots or private identifiers become evidence.
*******************************************************************************/
@Test
fun echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
check(context.packageName == "io.qrun.mobile.scanner.e2e") { "Echo requires the disposable E2E package" }
val configFile = File(context.filesDir, "e2e.json")
val config = try { JSONObject(configFile.readText()) }
catch(exception: Exception) { throw AssertionError("Echo fixture configuration is invalid") }
finally { configFile.delete() }
check(config.optString("fixtureType") == "echo-ui-e2e" && config.optBoolean("simulatedPrinting"))
origin = config.getString("serverUrl").trimEnd('/')
val endpoint = URI(origin)
check(endpoint.scheme == "http" && endpoint.host == "127.0.0.1" && endpoint.port in 1..65535 &&
endpoint.port !in setOf(57832, 51631, 18081, 18086) && endpoint.path in setOf("", "/") &&
endpoint.userInfo == null && endpoint.query == null && endpoint.fragment == null)
waitFor("QQQ server URL")
ui.onNodeWithText("QQQ server URL").performTextReplacement("$origin/")
ui.onNodeWithText("Connect", substring = false).performClick()
waitFor("Username")
ui.onNodeWithText("Username").performTextReplacement("scanner")
ui.onNodeWithText("Password").performTextReplacement(config.getString("scannerPassword"))
ui.onNodeWithText("Password").performImeAction()
ui.onNode(hasText("Sign in") and hasClickAction()).performScrollTo().performClick()
waitFor("An administrator must approve this device before you can run mobile apps.")
val device = DeviceCredentials(context, "$origin/")
adminCookie = loginAdmin(config.getString("adminPassword"))
runAdmin("mobileDeviceApprove", device.deviceId)
ui.onNodeWithText("Check approval").performScrollTo().performClick()
waitForTag("appHome.rowForApp:echo")
val scannerCookie = loginAdmin(config.getString("scannerPassword"), "scanner")
/*******************************************************************************
** Verify persisted rows through ordinary QQQ reads under the operator's real actor/device locks.
*******************************************************************************/
fun rows(table: String): List<JSONObject> {
check(table in setOf("echoScan", "echoFixtureRun"))
val request = Request.Builder().url("$origin/qqq/v1/table/$table/query").header("Cookie", scannerCookie)
.post("{}".toRequestBody("application/json".toMediaType()))
device.headers().forEach { (name, value) -> request.header(name, value) }
val records = executeJson(request.build()).getJSONArray("records")
return (0 until records.length()).map { records.getJSONObject(it).getJSONObject("values") }
}
val priorRuns = rows("echoFixtureRun").map { it.getString("id") }.toSet()
ui.onNodeWithTag("appHome.rowForApp:echo").performClick()
waitForTag("appHome.rowForProcess:echoPrint")
ui.onNodeWithTag("appHome.rowForProcess:echoPrint").performClick()
waitFor("Printer")
waitFor("Paper or label size")
ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick()
waitFor("Simulated printer")
ui.onNodeWithText("Simulated printer").performClick()
// Media is a contingent field: it stays disabled until the server returns choices for the selected printer.
ui.waitUntil(timeoutMillis = 60_000) { ui.onAllNodes(hasText("Choose a value") and isEnabled()).fetchSemanticsNodes().isNotEmpty() }
ui.onAllNodesWithText("Choose a value").onFirst().performScrollTo().performClick()
waitFor("Simulated 4 x 6 in — 300 dpi")
ui.onNodeWithText("Simulated 4 x 6 in — 300 dpi").performClick()
ui.onNodeWithText("Next", substring = false).performClick()
waitFor("Scanner ready. Press the handheld trigger.")
assertTrue("Echo must not offer manual barcode input", ui.onAllNodesWithText("Manual barcode").fetchSemanticsNodes().isEmpty())
assertTrue("Echo must not offer manual submission", ui.onAllNodesWithText("Submit barcode").fetchSemanticsNodes().isEmpty())
val run = rows("echoFixtureRun").single { it.getString("id") !in priorRuns }
val runId = run.getString("id")
assertTrue("Setup must pin the selected printer and media once", run.getString("state") == "OPEN" &&
run.getString("printerRecordId") == "fixture-printer" && run.getString("mediaId") == "fixture-4x6")
stage("echo-setup-selected")
var receipt = ""
for ((index, sample) in listOf("001234567890" to "LABEL-TYPE-CODE128", "000-ECHO-QR" to "QRCODE").withIndex()) {
waitFor("Scanner ready. Press the handheld trigger.")
context.sendBroadcast(Intent("io.qrun.mobile.scanner.SCAN").setPackage(context.packageName)
.addCategory(Intent.CATEGORY_DEFAULT).putExtra("com.symbol.datawedge.data_string", sample.first)
.putExtra("com.symbol.datawedge.label_type", sample.second))
var accepted: JSONObject? = null
waitForServer {
accepted = rows("echoScan").singleOrNull { it.optString("runId") == runId && it.optString("barcode") == sample.first && it.optString("state") == "QUEUED" }
accepted != null
}
val row = requireNotNull(accepted)
receipt = row.getString("mobileScanId")
waitFor("Print queued $receipt", substring = true)
assertTrue("Accepted Echo scans must preserve exact type and approved-device attribution",
row.getString("symbology") == sample.second && row.getString("rendererSymbology") == (if (index == 0) "code128" else "qr") &&
row.getString("scanSource") == "e2e-fixture" && row.getString("deviceId") == device.deviceId && row.getString("actorId") == "scanner")
assertTrue("Only a synthetic accepted job may acknowledge this fixture scan",
row.getString("id") == receipt && row.getString("printJobId") == "fixture-job-$receipt" && row.getString("eventId").isNotBlank())
assertTrue("Each event must create exactly one accepted Echo scan", rows("echoScan").count { it.optString("runId") == runId } == index + 1)
stage(if (index == 0) "echo-first-queued" else "echo-second-queued")
}
ui.activityRule.scenario.onActivity { OrientationPolicy.LANDSCAPE.select(it) }
waitFor("Print queued $receipt", substring = true)
ui.activityRule.scenario.recreate()
waitFor("Print queued $receipt", substring = true)
assertTrue("Recreation must retain scan phase without showing printer selection", ui.onAllNodesWithText("Choose a value").fetchSemanticsNodes().isEmpty())
val acceptedRows = rows("echoScan").filter { it.optString("runId") == runId }
assertTrue("Rotation/recreation must not accept another event", acceptedRows.size == 2 && acceptedRows.map { it.getString("eventId") }.toSet().size == 2 &&
acceptedRows.map { it.getString("printJobId") }.toSet().size == 2)
stage("echo-recreation-stable")
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.home").performClick()
waitFor("Leave process")
ui.onNodeWithText("Leave process", substring = false).performClick()
waitForTag("appHome.rowForApp:echo")
waitForServer { rows("echoFixtureRun").single { it.getString("id") == runId }.getString("state") == "CLOSED" }
assertTrue("Leaving must close this run without accepting extra print work", rows("echoScan").count { it.optString("runId") == runId } == 2)
stage("echo-run-closed")
}
/*******************************************************************************
** Real host/native activity-result boundaries and real upload APIs, using synthetic images only.
*******************************************************************************/
+8
View File
@@ -0,0 +1,8 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
// Explicit disposable native Echo acceptance; the fixture's printer/jobs are simulated.
import { runScannerE2e } from './scanner-e2e-runner.mjs';
const args = process.argv.slice(2);
if (args.length !== 4 || args[0] !== '--serial' || args[2] !== '--fixture' || !args[1] || !args[3]) {
throw new Error('Usage: node scripts/ci/scanner-device-echo-e2e.mjs --serial TC22_SERIAL --fixture PRIVATE_ECHO_FIXTURE.json');
}
await runScannerE2e({ physical: true, serial: args[1], scenario: 'echo', fixture: args[3] });
+109 -21
View File
@@ -9,6 +9,48 @@ import { basename, delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startSemanticApiProxy } from './semantic-api-proxy.mjs';
/*******************************************************************************
** Admit only the explicitly simulated private Echo fixture, before any ADB call.
*******************************************************************************/
export function validateEchoFixtureDescriptor(fixture) {
let url;
try { url = new URL(fixture?.baseUrl); } catch { throw new Error('Echo requires its private isolated loopback fixture.'); }
if (fixture.fixtureType !== 'echo-ui-e2e' || fixture.simulatedPrinting !== true ||
url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || ['57832', '51631', '18081', '18086'].includes(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('Echo requires fixtureType echo-ui-e2e, simulated printing and isolated private credentials; normal and printing runtimes are forbidden.');
}
return url;
}
/*******************************************************************************
** An Echo descriptor cannot substitute an ordinary demo or another contract.
*******************************************************************************/
export function validateEchoFixtureContract(version, branding) {
const extension = Array.isArray(version?.extensions) ? version.extensions.find(value => value.name === 'mobile-scanners') : null;
if (version?.apiVersion !== '1.10.0' || extension?.version !== '1.4.0' || extension.pathPrefix !== '/qbit/mobile-scanners' ||
branding?.displayName !== 'Echo UI E2E — simulated printing') {
throw new Error('Echo requires the simulated Echo fixture with core UI API1.10 and mobile-scanners1.4.');
}
}
/*******************************************************************************
** Compare raw identity only internally; never return it as command evidence.
*******************************************************************************/
export function physicalSerialMatches(expected, output) {
return typeof expected === 'string' && expected.length > 0 && typeof output === 'string' && output.trim() === expected;
}
/*******************************************************************************
** Strip selected device and opaque identifiers from Echo reports and errors.
*******************************************************************************/
export function redactEchoOutput(text, serial) {
return text.replaceAll(serial, '<DEVICE>').replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<ID>')
.replace(/\b[0-9a-f]{32,64}\b/gi, '<ID>').replace(/\bscanner-[0-9a-f]{24}\b/gi, '<DEVICE>');
}
/** Shared implementation; physical execution is exposed only by the explicit device entry point. */
export async function runScannerE2e(options = {}) {
const repository = resolve(fileURLToPath(new URL('../..', import.meta.url)));
@@ -19,14 +61,18 @@ export async function runScannerE2e(options = {}) {
const selectedScenario = options.scenario ?? process.env.E2E_SCENARIO;
const physical = options.physical === true;
const deviceScreen = selectedScenario === 'device-screen';
const echo = selectedScenario === 'echo';
if (echo && (!physical || !options.fixture || !serial || serial.startsWith('emulator-') || !/^[A-Za-z0-9._:-]+$/.test(serial))) {
throw new Error('Echo acceptance requires an explicit TC22 serial and private Echo fixture.');
}
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);
const url = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl);
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';
echo || 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) {
@@ -41,8 +87,11 @@ export async function runScannerE2e(options = {}) {
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 redact = (text) => {
const safe = text.replaceAll(adminPassword, '<REDACTED>').replaceAll(scannerPassword, '<REDACTED>');
return echo ? redactEchoOutput(safe, serial) : safe;
};
const run = (command, args, input, checkOutput) => 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') ? (deviceScreen ? 720_000 : 300_000) : 180_000;
@@ -68,7 +117,7 @@ export async function runScannerE2e(options = {}) {
child.on('error', (error) => { clearTimeout(deadline); reject(error); });
child.on('close', (code) => {
clearTimeout(deadline);
if (code === 0) resolveRun(redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`)));
if (code === 0) resolveRun(checkOutput ? checkOutput(output) === true : redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`)));
});
child.stdin.end(input);
});
@@ -76,14 +125,15 @@ export async function runScannerE2e(options = {}) {
const grantE2eLocationPermissions = async () => {
// These scenarios exercise scanner/navigation behavior, not the Android permission dialog.
// Never grant permissions to the operator's normal app or the separate consent-based QA app.
if (packageName !== 'io.qrun.mobile.scanner.e2e') return;
if (packageName !== 'io.qrun.mobile.scanner.e2e' || echo) return;
await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_COARSE_LOCATION');
await adbRun('shell', 'pm', 'grant', packageName, 'android.permission.ACCESS_FINE_LOCATION');
};
const emulated = (await adbRun('shell', 'getprop', 'ro.kernel.qemu')).trim() === '1';
if (physical) {
if (!serial || serial.startsWith('emulator-') || emulated || (await adbRun('get-serialno')).trim() !== serial) {
if (!serial || serial.startsWith('emulator-') || emulated ||
!await run(adb, [...adbPrefix, 'get-serialno'], undefined, output => physicalSerialMatches(serial, output))) {
throw new Error('Physical acceptance requires an explicitly selected non-emulator serial.');
}
const manufacturer = (await adbRun('shell', 'getprop', 'ro.product.manufacturer')).trim();
@@ -114,7 +164,7 @@ export async function runScannerE2e(options = {}) {
}
console.log(`Native ${scenario} acceptance passed (local UI, no server).`);
};
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen'] : []), ...Object.keys(localScenarios), 'pixel-copy', 'unsupported-api', 'missing-extension', 'missing-channel', 'compatible', 'additive', 'admin-forms', 'session-monitor'];
const allowedScenarios = [...(physical ? ['navigation', 'photos', 'device-screen', 'echo'] : []), ...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 = [];
@@ -172,6 +222,8 @@ export async function runScannerE2e(options = {}) {
let reverseAdded = false;
let screenEvidence;
let screenArtifacts;
let echoEvidence;
let echoArtifacts;
server?.on('error', (error) => { serverError = error; });
try {
for (const setting of physical ? [] : ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
@@ -192,22 +244,34 @@ export async function runScannerE2e(options = {}) {
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 (deviceScreen || echo) {
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.');
if (actual !== packageName + (index ? '.test' : '')) throw new Error('Refusing an APK outside the exact disposable acceptance 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));
if (deviceScreen) {
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));
} else {
const branding = await fetch(`${origin}/qqq/v1/qbit/mobile-scanners/branding`, { signal: AbortSignal.timeout(10_000) }).then(response => {
if (!response.ok) throw new Error('Echo fixture branding discovery failed.'); return response.json();
});
validateEchoFixtureContract(version, branding);
if (['57832', '51631', '18081', '18086'].includes(String(proxy.port))) throw new Error('Refusing a protected reverse port.');
echoArtifacts = await Promise.all(apkPaths.map(async (apk, index) => ({ applicationId: packageName + (index ? '.test' : ''), sha256: createHash('sha256').update(await readFile(apk)).digest('hex') })));
echoEvidence = join(repository, 'scanner-host/build/native-device-e2e');
await mkdir(echoEvidence, { recursive: true });
await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PREPARING', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts }, null, 2));
}
}
await adbRun('reverse', '--no-rebind', `tcp:${proxy.port}`, `tcp:${proxy.port}`);
reverseAdded = true;
@@ -260,12 +324,18 @@ export async function runScannerE2e(options = {}) {
{ mode: 'compatible', name: 'admin-forms', test: 'adminFormsApproveRevokeAndCancel', requireCancel: true },
{ mode: 'compatible', name: 'session-monitor', test: 'sessionActivityMirrorsActualScreenAndAdminEndForcesFreshLogin', requireSession: true },
...(fixture ? [{ mode: 'compatible', name: 'photos', test: 'cameraAutoUploadsAndPendingGallerySurvivesRotationThenCleansOnRemoteLogout', requirePhotos: true }] : []),
...(echo ? [{ mode: 'compatible', name: 'echo', test: 'echoSelectsOnceQueuesTwoScansAndClosesOnAppsLeave', requireCancel: true }] : []),
]) {
if (selectedScenario && selectedScenario !== (phase.name ?? phase.mode)) continue;
proxy.setMode(phase.mode);
await adbRun('shell', 'pm', 'clear', packageName);
// Clearing disposable app data also resets runtime permissions on fresh Android images.
if ((phase.name ?? phase.mode) === 'session-monitor' && packageName === 'io.qrun.mobile.scanner.e2e') {
if (echo) {
// Echo does not qualify GPS: keep reporting off only in this new disposable app.
await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs');
await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > shared_prefs/host.xml'"],
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?><map><boolean name="locationReportingEnabled" value="false" /><boolean name="locationPermissionRequested" value="true" /></map>');
} else if ((phase.name ?? phase.mode) === 'session-monitor' && packageName === 'io.qrun.mobile.scanner.e2e') {
// Preserve this scenario's real denied-location telemetry assertion without an OS dialog.
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_FINE_LOCATION');
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_COARSE_LOCATION');
@@ -277,7 +347,8 @@ export async function runScannerE2e(options = {}) {
}
await adbRun('shell', 'run-as', packageName, 'mkdir', '-p', 'files');
await run(adb, [...adbPrefix, 'shell', 'run-as', packageName, 'sh', '-c', "'cat > files/e2e.json'"],
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError }));
JSON.stringify({ serverUrl: `http://127.0.0.1:${proxy.port}/`, adminPassword, scannerPassword, expectedError: phase.expectedError,
...(echo ? { fixtureType: 'echo-ui-e2e', simulatedPrinting: true } : {}) }));
const test = phase.test ?? (phase.expectedError ? 'incompatibleServerStopsBeforeLogin' : 'loginEnrollApproveCaptureAndRevoke');
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
'-e', 'class', `io.qrun.mobile.scanner.ScannerEndToEndTest#${test}`,
@@ -288,29 +359,40 @@ export async function runScannerE2e(options = {}) {
throw new Error(`Native scanner E2E (${phase.mode}) failed:\n${output}`);
}
reports.push({ scenario: phase.name ?? phase.mode, ...proxy.verify(!phase.expectedError, Boolean(phase.requireCancel), Boolean(phase.requireSession), Boolean(phase.requirePhotos)) });
if (echo) {
const expectedStages = ['echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-recreation-stable', 'echo-run-closed'];
if (expectedStages.some(stage => !stages.has(stage))) throw new Error('Echo did not complete every setup/queue/recreation/cancellation stage.');
await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({ status: 'PASSED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true,
artifacts: echoArtifacts, completedStages: expectedStages, acceptedScans: 2, ...reports.at(-1), physicalScannerQualified: false, physicalPrintingQualified: false }, null, 2));
}
console.log(`Native semantic E2E passed: ${phase.name ?? phase.mode}.`);
}
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 (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
status: 'FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts, completedStages: [...stages],
}, null, 2));
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 (echo && echoEvidence && proxy) await writeFile(join(echoEvidence, 'echo-operations.json'), JSON.stringify(proxy.evidence(), null, 2))
.catch(() => { screenCleanupFailures.push('Echo semantic evidence'); });
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(() => {});
if (installedByRun.has(packageName)) await adbRun('shell', 'run-as', packageName, 'rm', '-f', 'files/e2e.json').catch(() => { if (echo) screenCleanupFailures.push('Echo credential file'); });
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'); });
for (const installed of [...installedByRun].reverse()) await adbRun('uninstall', installed).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('disposable package'); });
if (proxy) {
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen) screenCleanupFailures.push('owned reverse'); });
if (reverseAdded) await adbRun('reverse', '--remove', `tcp:${proxy.port}`).catch(() => { if (deviceScreen || echo) screenCleanupFailures.push('owned reverse'); });
await proxy.close();
}
const stopped = new Promise((done) => { if (!server || server.exitCode !== null || serverError) done(); else server.once('exit', done); });
@@ -323,7 +405,13 @@ 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(', ')}`);
if (screenCleanupFailures.length) {
if (echo && echoEvidence) await writeFile(join(echoEvidence, 'echo-proof.json'), JSON.stringify({
status: 'CLEANUP_FAILED', fixtureType: 'echo-ui-e2e', simulatedPrinting: true, artifacts: echoArtifacts,
completedStages: [...stages], cleanupFailures: screenCleanupFailures,
}, null, 2));
throw new Error(`Disposable acceptance cleanup/evidence incomplete: ${screenCleanupFailures.join(', ')}`);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright (c) 2026 QRunIO. SPDX-License-Identifier: Apache-2.0
import assert from 'node:assert/strict';
import test from 'node:test';
import { runScannerE2e, validateEchoFixtureDescriptor, validateEchoFixtureContract, physicalSerialMatches, redactEchoOutput } from './scanner-e2e-runner.mjs';
const fixture = {
fixtureType: 'echo-ui-e2e', simulatedPrinting: true, baseUrl: 'http://127.0.0.1:49152/',
username: 'scanner', password: 'synthetic-scanner-password', adminUsername: 'admin', adminPassword: 'synthetic-admin-password',
};
const version = { apiVersion: '1.10.0', extensions: [{ name: 'mobile-scanners', version: '1.4.0', pathPrefix: '/qbit/mobile-scanners' }] };
const branding = { displayName: 'Echo UI E2E — simulated printing' };
/*******************************************************************************
** Identity is checked before evidence redaction; only the comparison escapes.
*******************************************************************************/
test('raw serial matches exactly and a different physical device is refused', () => {
const serial = 'SYNTHETIC_TC22_SERIAL';
assert.equal(physicalSerialMatches(serial, `${serial}\r\n`), true);
assert.equal(physicalSerialMatches(serial, 'ANOTHER_DEVICE\r\n'), false);
assert.equal(physicalSerialMatches(serial, redactEchoOutput(`${serial}\r\n`, serial)), false);
});
/*******************************************************************************
** Serial-bearing successful output and errors remain safe to persist as reports.
*******************************************************************************/
test('Echo artifacts and error text contain no selected serial', () => {
const serial = 'SYNTHETIC_TC22_SERIAL';
const artifact = JSON.stringify({ output: redactEchoOutput(`${serial}\nOK (1 test)`, serial),
error: redactEchoOutput(`Device ${serial} failed`, serial) });
assert.equal(artifact.includes(serial), false);
assert.equal(artifact.includes('<DEVICE>'), true);
});
/*******************************************************************************
** A valid descriptor is inspected without contacting a server or any device.
*******************************************************************************/
test('explicit simulated Echo descriptor is accepted', () => {
assert.equal(validateEchoFixtureDescriptor(fixture).port, '49152');
validateEchoFixtureContract(version, branding);
});
/*******************************************************************************
** Existing scanner and printing listeners can never become this test's target.
*******************************************************************************/
test('all protected normal and printing ports are refused', () => {
for (const port of [57832, 51631, 18081, 18086]) {
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl: `http://127.0.0.1:${port}/` }));
}
});
/*******************************************************************************
** URL decoration cannot turn the private fixture into another host or route.
*******************************************************************************/
test('non-private or decorated fixture origins are refused', () => {
for (const baseUrl of ['https://127.0.0.1:49152/', 'http://example.invalid:49152/', 'http://127.0.0.1/',
'http://127.0.0.1:49152/path', 'http://127.0.0.1:49152/?value=1', 'http://127.0.0.1:49152/#fragment',
'http://scanner:secret@127.0.0.1:49152/', 'not-a-url']) {
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, baseUrl }));
}
});
/*******************************************************************************
** Purpose, simulation and private account declarations must all match exactly.
*******************************************************************************/
test('wrong-purpose or incomplete credentials are refused', () => {
for (const changed of [{ fixtureType: 'device-screen-qa' }, { simulatedPrinting: false }, { simulatedPrinting: 'true' },
{ username: 'operator' }, { adminUsername: 'owner' }, { password: null }, { password: 'short' }, { adminPassword: 'short' }]) {
assert.throws(() => validateEchoFixtureDescriptor({ ...fixture, ...changed }));
}
});
/*******************************************************************************
** Another real demo or an unqualified contract cannot masquerade as this fixture.
*******************************************************************************/
test('contract and simulated branding must match the prepared cohort', () => {
assert.throws(() => validateEchoFixtureContract({ ...version, apiVersion: '1.9.0' }, branding));
assert.throws(() => validateEchoFixtureContract({ ...version, extensions: [{ ...version.extensions[0], version: '1.3.0' }] }, branding));
assert.throws(() => validateEchoFixtureContract(version, { displayName: 'QQQ Mobile' }));
});
/*******************************************************************************
** These calls stop before reading a fixture or spawning ADB, even when selected
** through the generic emulator entry or an incomplete physical invocation.
*******************************************************************************/
test('Echo requires its explicit physical entry inputs before device operations', async () => {
await assert.rejects(runScannerE2e({ scenario: 'echo' }), /explicit TC22/);
await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json' }), /explicit TC22/);
await assert.rejects(runScannerE2e({ scenario: 'echo', physical: true, fixture: 'unused.json', serial: 'emulator-5554' }), /explicit TC22/);
});