QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Android CI / unit-test (pull_request) Successful in 4m25s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Android CI / android-test (pull_request) Successful in 9m50s
QRun-IO/ci-policy: Shared Android CI / secrets (pull_request) Successful in 19s
QRun-IO/ci-policy: Shared Android CI / package (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared Android CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / android-0 (pull_request) Successful in 15m3s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Add scan value and symbology feedback, optional status wallpaper and managed server/orientation configuration, proven AIM profile readback, and administrator-controlled viewing UI. Extend native Echo acceptance for per-scan copies and add an opt-in real-provider Authentik harness isolated to the disposable test package. Verified: 222 JVM tests, lint with zero errors/warnings, debug/E2E/test APK builds, 23 Node tests and source standards. Updated TC22 Echo acceptance passes ten stages with simulated input/printing. Actual Authentik desktop acceptance passes; native Authentik and physical trigger/print validation remain pending. Normal TC22 updated with the matching signer and data preserved. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
419 lines
32 KiB
JavaScript
419 lines
32 KiB
JavaScript
// 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, createHash } from 'node:crypto';
|
|
import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
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' || !['1.4.0', '1.5.0'].includes(extension?.version) || 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 or 1.5.');
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** 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)));
|
|
const qbit = resolve(options.qbit ?? join(repository, '../qbit-mobile-scanners'));
|
|
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
|
|
const adb = sdk ? join(sdk, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb') : 'adb';
|
|
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';
|
|
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 = echo ? validateEchoFixtureDescriptor(fixture) : new URL(fixture.baseUrl);
|
|
const purposeValid = deviceScreen ? fixture.purpose === 'device-screen-qa' && !['57832', '51631'].includes(url.port) :
|
|
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) {
|
|
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 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) => {
|
|
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;
|
|
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) => {
|
|
output += data;
|
|
if (args.includes('instrument')) {
|
|
statusBuffer += data;
|
|
const lines = statusBuffer.split(/\r?\n/);
|
|
statusBuffer = lines.pop();
|
|
for (const line of lines) {
|
|
if (/^INSTRUMENTATION_STATUS: qqqStage=[A-Za-z0-9._ -]+$/.test(line)) {
|
|
stages.add(line.substring(line.indexOf('qqqStage=') + 9));
|
|
console.log(line);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
child.stderr.on('data', (data) => { output += data; });
|
|
child.on('error', (error) => { clearTimeout(deadline); reject(error); });
|
|
child.on('close', (code) => {
|
|
clearTimeout(deadline);
|
|
if (code === 0) resolveRun(checkOutput ? checkOutput(output) === true : redact(output)); else reject(new Error(redact(`${command} failed (${code}): ${output}`)));
|
|
});
|
|
child.stdin.end(input);
|
|
});
|
|
const adbRun = (...args) => run(adb, [...adbPrefix, ...args]);
|
|
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' || 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 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();
|
|
const model = (await adbRun('shell', 'getprop', 'ro.product.model')).trim();
|
|
if (!/^zebra/i.test(manufacturer) || !/^TC22(?:$|[ _-])/i.test(model)) throw new Error('This physical acceptance entry point is limited to the approved Zebra TC22.');
|
|
const installed = (await adbRun('shell', 'pm', 'list', 'packages', '-u', packageName)).split(/\r?\n/).map(line => line.trim());
|
|
if (installed.includes('package:' + packageName) || installed.includes('package:' + packageName + '.test')) {
|
|
throw new Error('Disposable test packages already exist. Refusing to replace or clear an existing installation.');
|
|
}
|
|
} else {
|
|
if (!emulated) throw new Error('Refusing scanner E2E on a physical device. Use the separate explicit device-test entry point.');
|
|
await adbRun('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP');
|
|
await adbRun('shell', 'wm', 'dismiss-keyguard');
|
|
}
|
|
const localScenarios = {
|
|
licenses: 'io.qrun.mobile.scanner.OpenSourceLicensesTest',
|
|
orientation: 'io.qrun.mobile.scanner.OrientationPolicyTest',
|
|
diagnostics: 'io.qrun.mobile.scanner.DiagnosticScreenTest',
|
|
};
|
|
const runLocalScenario = async (scenario) => {
|
|
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
|
|
'-e', 'class', localScenarios[scenario], `${packageName}.test/androidx.test.runner.AndroidJUnitRunner`);
|
|
const evidence = join(repository, physical ? 'scanner-host/build/native-device-e2e' : 'scanner-host/build/native-e2e');
|
|
await mkdir(evidence, { recursive: true });
|
|
await writeFile(join(evidence, `${scenario}-instrumentation.txt`), output);
|
|
if (!/OK \(1 test\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) {
|
|
throw new Error(`Native ${scenario} acceptance failed; inspect the isolated test evidence.`);
|
|
}
|
|
console.log(`Native ${scenario} acceptance passed (local UI, no server).`);
|
|
};
|
|
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 = [];
|
|
try {
|
|
await adbRun('install', ...(physical ? [] : ['-r']), join(repository, 'scanner-host/build/outputs/apk/e2e/scanner-host-e2e.apk'));
|
|
temporary.push(packageName);
|
|
await adbRun('install', ...(physical ? [] : ['-r']), join(repository, 'scanner-host/build/outputs/apk/androidTest/e2e/scanner-host-e2e-androidTest.apk'));
|
|
temporary.push(packageName + '.test');
|
|
await grantE2eLocationPermissions();
|
|
await runLocalScenario(selectedScenario);
|
|
} finally {
|
|
for (const installed of temporary.reverse()) await adbRun('uninstall', installed);
|
|
}
|
|
return;
|
|
}
|
|
if (selectedScenario === 'navigation') {
|
|
const sample = 'com.kingsrook.qqq.sampleandroidmobileapp';
|
|
const existing = (await adbRun('shell', 'pm', 'list', 'packages', '-u', sample)).split(/\r?\n/).map(line => line.trim());
|
|
if (existing.includes('package:' + sample) || existing.includes('package:' + sample + '.test')) throw new Error('Refusing to replace an existing sample/navigation test installation.');
|
|
const temporary = [];
|
|
try {
|
|
await adbRun('install', join(repository, 'qqqsampleandroidmobileapp/build/outputs/apk/debug/qqqsampleandroidmobileapp-debug.apk'));
|
|
temporary.push(sample);
|
|
await adbRun('install', join(repository, 'qqqsampleandroidmobileapp/build/outputs/apk/androidTest/debug/qqqsampleandroidmobileapp-debug-androidTest.apk'));
|
|
temporary.push(sample + '.test');
|
|
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
|
|
'-e', 'class', 'com.kingsrook.qqq.sampleandroidmobileapp.NavigationTests,com.kingsrook.qqq.sampleandroidmobileapp.SemanticInputTests,com.kingsrook.qqq.sampleandroidmobileapp.SessionTests,com.kingsrook.qqq.sampleandroidmobileapp.OAuth2LoginTests,com.kingsrook.qqq.sampleandroidmobileapp.SampleHostSecurityTests,com.kingsrook.qqq.sampleandroidmobileapp.AuthenticationLifecycleTests',
|
|
sample + '.test/androidx.test.runner.AndroidJUnitRunner');
|
|
const evidence = join(repository, 'scanner-host/build/native-device-e2e');
|
|
await mkdir(evidence, { recursive: true });
|
|
await writeFile(join(evidence, 'navigation-instrumentation.txt'), output);
|
|
if (!/OK \(32 tests\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Physical native navigation/input/account/OAuth acceptance failed; inspect the isolated test evidence.');
|
|
console.log('Physical native navigation/input/account/OAuth acceptance passed (32 tests, synthetic repository and browser result).');
|
|
} finally {
|
|
for (const installed of temporary.reverse()) await adbRun('uninstall', installed);
|
|
}
|
|
return;
|
|
}
|
|
const port = await new Promise((resolvePort, reject) => {
|
|
const server = createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => { const chosen = server.address().port; server.close(() => resolvePort(chosen)); });
|
|
});
|
|
const classpath = fixture ? null : [join(qbit, 'demo/target/classes'), (await readFile(join(qbit, 'demo/target/e2e-classpath.txt'), 'utf8')).trim()].join(delimiter);
|
|
const directory = fixture ? null : await mkdtemp(join(tmpdir(), 'qqq-native-e2e-'));
|
|
const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java';
|
|
const server = fixture ? null : spawn(java, ['-cp', classpath, 'com.kingsrook.qbits.mobilescanners.demo.MobileScannersDemo', String(port)], {
|
|
cwd: directory, windowsHide: true, stdio: 'ignore',
|
|
env: { ...process.env, MOBILE_DEMO_ADMIN_PASSWORD: adminPassword, MOBILE_DEMO_SCANNER_PASSWORD: scannerPassword },
|
|
});
|
|
let serverError;
|
|
let proxy;
|
|
const animationScales = new Map();
|
|
const installedByRun = new Set();
|
|
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']) {
|
|
const previous = (await adbRun('shell', 'settings', 'get', 'global', setting)).trim();
|
|
if (!/^(null|[0-9]+(?:\.[0-9]+)?)$/.test(previous)) throw new Error('Unexpected emulator animation setting.');
|
|
animationScales.set(setting, previous);
|
|
await adbRun('shell', 'settings', 'put', 'global', setting, '0');
|
|
}
|
|
const origin = fixture ? fixture.baseUrl.replace(/\/$/, '') : `http://127.0.0.1:${port}`;
|
|
const deadline = Date.now() + 60_000;
|
|
while (true) {
|
|
if (serverError) throw serverError;
|
|
if (server && server.exitCode !== null) throw new Error('Isolated QQQ demo exited before becoming ready.');
|
|
if (await fetch(`${origin}/qqq/v1/qbit/mobile-scanners/status`).then((response) => response.ok).catch(() => false)) break;
|
|
if (Date.now() > deadline) throw new Error('Isolated QQQ demo did not become ready. Build the qbit/demo and test classpath first.');
|
|
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 || 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 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 (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;
|
|
await adbRun('install', ...(physical ? ['--no-streaming'] : ['-r']), apkPaths[0]);
|
|
installedByRun.add(packageName);
|
|
await adbRun('install', ...(physical ? ['--no-streaming'] : ['-r']), apkPaths[1]);
|
|
installedByRun.add(packageName + '.test');
|
|
await grantE2eLocationPermissions();
|
|
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 = [];
|
|
if (!selectedScenario) {
|
|
for (const scenario of Object.keys(localScenarios)) await runLocalScenario(scenario);
|
|
}
|
|
if (!selectedScenario || selectedScenario === 'pixel-copy') {
|
|
const output = await adbRun('shell', 'am', 'instrument', '-w', '-r',
|
|
'-e', 'class', 'io.qrun.mobile.scanner.AppScreenCaptureTest',
|
|
`${packageName}.test/androidx.test.runner.AndroidJUnitRunner`);
|
|
await writeFile(join(evidence, 'pixel-copy-instrumentation.txt'), output);
|
|
if (!/OK \(1 test\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) {
|
|
throw new Error(`Native PixelCopy E2E failed:\n${output}`);
|
|
}
|
|
console.log('Native PixelCopy E2E passed.');
|
|
}
|
|
for (const phase of [
|
|
{ mode: 'unsupported-api', expectedError: 'Unsupported QQQ UI API version.' },
|
|
{ mode: 'missing-extension', expectedError: 'This server is missing the mobile-scanners extension.' },
|
|
{ mode: 'missing-channel', expectedError: 'This server does not support the Android client channel.' },
|
|
{ mode: 'compatible' },
|
|
{ mode: 'additive' },
|
|
{ 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 (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') {
|
|
// Preserve this scenario's real denied-location telemetry assertion without an OS dialog.
|
|
// Package selection already limits this to the disposable e2e or physical qa application.
|
|
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_FINE_LOCATION');
|
|
await adbRun('shell', 'pm', 'revoke', packageName, 'android.permission.ACCESS_COARSE_LOCATION');
|
|
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="locationPermissionRequested" value="true" /></map>');
|
|
} else {
|
|
await grantE2eLocationPermissions();
|
|
}
|
|
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,
|
|
...(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}`,
|
|
`${packageName}.test/androidx.test.runner.AndroidJUnitRunner`);
|
|
await writeFile(join(evidence, `${phase.name ?? phase.mode}-instrumentation.txt`), output);
|
|
await writeFile(join(evidence, `${phase.name ?? phase.mode}-operations.json`), `${JSON.stringify(proxy.evidence(), null, 2)}\n`);
|
|
if (!/OK \(1 test\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) {
|
|
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-copies-defaulted', 'echo-document-selected', 'echo-stale-document-cleared', 'echo-copies-selected', 'echo-setup-selected', 'echo-first-queued', 'echo-second-queued', 'echo-recreation-stable', 'echo-copies-persisted', '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 (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 || echo) screenCleanupFailures.push('disposable package'); });
|
|
if (proxy) {
|
|
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); });
|
|
server?.kill();
|
|
await Promise.race([stopped, new Promise((_, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('Isolated demo did not stop; its temporary database was retained.')), 5_000);
|
|
timer.unref();
|
|
})]);
|
|
if (directory && (dirname(resolve(directory)) !== resolve(tmpdir()) || !basename(directory).startsWith('qqq-native-e2e-'))) {
|
|
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) {
|
|
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(', ')}`);
|
|
}
|
|
}
|
|
|
|
}
|