diff --git a/test-apps/android-test-app/alarm-test-lib.sh b/test-apps/android-test-app/alarm-test-lib.sh index 7f59ccf..ddd8b3f 100644 --- a/test-apps/android-test-app/alarm-test-lib.sh +++ b/test-apps/android-test-app/alarm-test-lib.sh @@ -57,6 +57,138 @@ NC='\033[0m' # No Color # These are the primary functions that all scripts should use. # Deprecated functions (print_*, wait_for_*) are kept for backward compatibility. +# ======================================== +# Event Emission (Phase B - Live Console Updates) +# ======================================== + +# Initialize run ID for event emission +: "${DNP_RUN_ID:=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || echo 'unknown')}" + +# Emit test event to console via ADB broadcast +emit_event() { + # Usage: emit_event TYPE LEVEL [PHASE] [TEST] [STEP] [MESSAGE] [EXTRA_JSON] + # Example: emit_event "step_start" "INFO" "phase1" "phase1_test0" "p1_t0_s1" "Starting step" + local event_type="$1" + local level="${2:-INFO}" + local phase_id="${3:-}" + local test_id="${4:-}" + local step_id="${5:-}" + local message="${6:-}" + local extra_json="${7:-}" + + # Only emit if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" != "1" ]; then + return 0 + fi + + # Build JSON payload using Python (safer than shell JSON escaping) + local payload + payload=$(python3 -c " +import json +import sys +from datetime import datetime + +event = { + 'version': 'testevent.v1', + 'ts': datetime.now().isoformat(), + 'runId': '${DNP_RUN_ID}', + 'type': '${event_type}', + 'level': '${level}', + 'phaseId': '${phase_id}', + 'testId': '${test_id}', + 'stepId': '${step_id}', + 'message': '${message}' +} + +# Add extra JSON fields if provided +if '${extra_json}': + try: + extra = json.loads('${extra_json}') + event.update(extra) + except: + pass + +print(json.dumps(event)) +" 2>/dev/null) + + if [ -z "$payload" ]; then + # Fallback: simple JSON without Python + payload="{\"version\":\"testevent.v1\",\"ts\":\"$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')\",\"runId\":\"${DNP_RUN_ID}\",\"type\":\"${event_type}\",\"level\":\"${level}\",\"phaseId\":\"${phase_id}\",\"testId\":\"${test_id}\",\"stepId\":\"${step_id}\",\"message\":\"${message}\"}" + fi + + # Send via ADB broadcast + adb_broadcast_event "$payload" +} + +# Send event via ADB broadcast +adb_broadcast_event() { + local payload="$1" + local action="com.timesafari.dailynotification.TEST_EVENT" + + # Escape payload for shell (single quotes are safest) + # Replace single quotes with '\'' (end quote, escaped quote, start quote) + local escaped_payload + escaped_payload=$(echo "$payload" | sed "s/'/'\\\\''/g") + + # Send broadcast + $ADB_BIN shell am broadcast \ + -a "$action" \ + --es payload "$escaped_payload" \ + >/dev/null 2>&1 || true +} + +# Set test context (phase/test/step) for event emission +set_test_context() { + # Usage: set_test_context PHASE_ID TEST_ID STEP_ID + export DNP_PHASE="${1:-}" + export DNP_TEST="${2:-}" + export DNP_STEP="${3:-}" +} + +# Emit step start event +step_start() { + # Usage: step_start STEP_ID [MESSAGE] + local step_id="${1:-${DNP_STEP}}" + local message="${2:-Starting step}" + + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "step_start" "INFO" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" + fi +} + +# Emit step pass event +step_pass() { + # Usage: step_pass STEP_ID [MESSAGE] + local step_id="${1:-${DNP_STEP}}" + local message="${2:-Step completed}" + + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "step_pass" "INFO" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" + fi +} + +# Emit step warn event +step_warn() { + # Usage: step_warn STEP_ID [MESSAGE] + local step_id="${1:-${DNP_STEP}}" + local message="${2:-Step warning}" + + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "step_warn" "WARN" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" + fi +} + +# Emit step fail event +step_fail() { + # Usage: step_fail STEP_ID [MESSAGE] + local step_id="${1:-${DNP_STEP}}" + local message="${2:-Step failed}" + + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "step_fail" "ERROR" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" + fi +} + section() { echo echo "========================================" @@ -97,6 +229,12 @@ ui_prompt() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo -e "$1" echo + + # Emit operator_required event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "operator_required" "WAIT" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "$1" + fi + read -rp "Press Enter after completing the action above..." echo } @@ -740,6 +878,13 @@ capture_alarms() { info "Capturing alarms: $label → $file" if $ADB_BIN shell dumpsys alarm > "$file" 2>/dev/null; then ok "Alarms captured: $file" + + # Emit artifact event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + local artifact_json="{\"kind\":\"alarms\",\"name\":\"${label}_alarms\",\"path\":\"${file}\"}" + emit_event "artifact" "EVID" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "Alarms captured: $label" "$artifact_json" + fi + return 0 else warn "Failed to capture alarms: $label" @@ -771,6 +916,13 @@ capture_logcat() { if [ -n "$pattern" ]; then if $ADB_BIN logcat -d -t "$lines" | grep -E "$pattern" > "$file" 2>/dev/null; then ok "Logcat captured (filtered): $file" + + # Emit artifact event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + local artifact_json="{\"kind\":\"logs\",\"name\":\"${label}_logcat\",\"path\":\"${file}\"}" + emit_event "artifact" "EVID" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "Logcat captured: $label" "$artifact_json" + fi + return 0 else # Even if grep finds nothing, create empty file to indicate attempt @@ -781,6 +933,13 @@ capture_logcat() { else if $ADB_BIN logcat -d -t "$lines" > "$file" 2>/dev/null; then ok "Logcat captured: $file" + + # Emit artifact event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + local artifact_json="{\"kind\":\"logs\",\"name\":\"${label}_logcat\",\"path\":\"${file}\"}" + emit_event "artifact" "EVID" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "Logcat captured: $label" "$artifact_json" + fi + return 0 else warn "Failed to capture logcat: $label" @@ -815,6 +974,13 @@ capture_screenshot() { if "$ADB_BIN" exec-out screencap -p > "$file" 2>/dev/null; then if [ -s "$file" ]; then ok "Screenshot captured: $file" + + # Emit artifact event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + local artifact_json="{\"kind\":\"screen\",\"name\":\"${label}_screenshot\",\"path\":\"${file}\"}" + emit_event "artifact" "EVID" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "Screenshot captured: $label" "$artifact_json" + fi + return 0 else warn "Screenshot file is empty: $file" @@ -881,6 +1047,11 @@ verdict_pass() { echo "Next: Continue to next test" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo + + # Emit test_verdict event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "test_verdict" "INFO" "${DNP_PHASE:-}" "$test_id" "" "Test passed: $message" + fi } verdict_warn() { @@ -904,6 +1075,11 @@ verdict_warn() { echo "Next: Review evidence and continue" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo + + # Emit test_verdict event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "test_verdict" "WARN" "${DNP_PHASE:-}" "$test_id" "" "Test warning: $message" + fi } verdict_fail() { @@ -929,6 +1105,11 @@ verdict_fail() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo + # Emit test_verdict event if UI events enabled + if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then + emit_event "test_verdict" "ERROR" "${DNP_PHASE:-}" "$test_id" "" "Test failed: $message" + fi + # If release gating is enabled, exit with failure if [ "${RELEASE_GATE_PHASE3:-0}" = "1" ]; then error "Release gating enabled: exiting due to test failure" diff --git a/test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json b/test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json index e661cf9..743c924 100644 --- a/test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json +++ b/test-apps/android-test-app/app/src/main/assets/capacitor.plugins.json @@ -2,5 +2,9 @@ { "name": "DailyNotification", "classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" + }, + { + "name": "TestEvents", + "classpath": "com.timesafari.dailynotification.TestEventsPlugin" } ] diff --git a/test-apps/android-test-app/app/src/main/assets/public/console/console.css b/test-apps/android-test-app/app/src/main/assets/public/console/console.css new file mode 100644 index 0000000..6e20d80 --- /dev/null +++ b/test-apps/android-test-app/app/src/main/assets/public/console/console.css @@ -0,0 +1,573 @@ +/* Console CSS - Textual-inspired design */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + font-size: 14px; + line-height: 1.5; + color: #e0e0e0; + background: #1e1e1e; + overflow: hidden; + height: 100vh; +} + +.console-container { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Header */ +.console-header { + background: #252526; + border-bottom: 1px solid #3e3e42; + padding: 12px 16px; + flex-shrink: 0; +} + +.header-row { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.header-row:first-child { + margin-bottom: 8px; +} + +.console-header h1 { + font-size: 18px; + font-weight: 600; + color: #ffffff; +} + +.console-header span { + font-size: 12px; + color: #cccccc; +} + +.console-header #device-id, +.console-header #run-id, +.console-header #mode, +.console-header #strictness { + color: #4ec9b0; + font-weight: 500; +} + +/* Main Content */ +.console-main { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; +} + +/* Sidebar */ +.console-sidebar { + width: 280px; + background: #252526; + border-right: 1px solid #3e3e42; + overflow-y: auto; + flex-shrink: 0; + padding: 16px; +} + +.sidebar-section h2 { + font-size: 14px; + font-weight: 600; + color: #cccccc; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.phase-item { + margin-bottom: 8px; +} + +.phase-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + cursor: pointer; + border-radius: 4px; + user-select: none; +} + +.phase-header:hover { + background: #2a2d2e; +} + +.phase-status { + font-size: 16px; + width: 20px; + text-align: center; +} + +.phase-title { + flex: 1; + font-size: 13px; + color: #cccccc; +} + +.phase-progress { + font-size: 11px; + color: #858585; +} + +.test-item { + margin-left: 28px; + margin-top: 4px; + padding: 6px 8px; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.test-item:hover { + background: #2a2d2e; +} + +.test-item.active { + background: #094771; +} + +.test-status { + font-size: 14px; + width: 18px; + text-align: center; +} + +.test-title { + flex: 1; + font-size: 12px; + color: #cccccc; +} + +/* Content Area */ +.console-content { + flex: 1; + overflow-y: auto; + padding: 20px; + background: #1e1e1e; +} + +.test-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #3e3e42; +} + +.test-header h2 { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; +} + +.test-purpose { + font-size: 13px; + color: #858585; + line-height: 1.6; +} + +.step-checklist-section { + margin-bottom: 24px; +} + +.step-checklist-section h3 { + font-size: 14px; + font-weight: 600; + color: #cccccc; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.step-checklist { + background: #252526; + border: 1px solid #3e3e42; + border-radius: 4px; + padding: 12px; +} + +.step-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + border-radius: 4px; + cursor: pointer; + margin-bottom: 4px; +} + +.step-item:hover { + background: #2a2d2e; +} + +.step-item.active { + background: #094771; +} + +.step-checkbox { + font-size: 16px; + width: 24px; + text-align: center; +} + +.step-number { + font-size: 12px; + color: #858585; + min-width: 60px; +} + +.step-name { + flex: 1; + font-size: 13px; + color: #cccccc; +} + +.step-type-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + background: #3e3e42; + color: #cccccc; + text-transform: uppercase; +} + +/* Current Step Frame */ +.current-step-section { + margin-bottom: 24px; +} + +.current-step-section h3 { + font-size: 14px; + font-weight: 600; + color: #cccccc; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.current-step-frame { + background: #252526; + border: 1px solid #3e3e42; + border-radius: 4px; + padding: 20px; +} + +.step-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #3e3e42; +} + +.step-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; +} + +.step-type { + font-size: 11px; + padding: 4px 8px; + border-radius: 3px; + background: #094771; + color: #4ec9b0; + text-transform: uppercase; +} + +.step-content { + margin-bottom: 20px; +} + +.step-section { + margin-bottom: 16px; +} + +.step-section h4 { + font-size: 13px; + font-weight: 600; + color: #4ec9b0; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.step-text { + font-size: 13px; + color: #cccccc; + line-height: 1.6; +} + +.step-list { + font-size: 13px; + color: #cccccc; + line-height: 1.8; +} + +.step-list ul { + list-style: none; + padding-left: 0; +} + +.step-list li { + padding-left: 20px; + position: relative; +} + +.step-list li:before { + content: "•"; + position: absolute; + left: 8px; + color: #4ec9b0; +} + +.step-actions { + display: flex; + gap: 12px; + padding-top: 16px; + border-top: 1px solid #3e3e42; +} + +/* Buttons */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-success { + background: #0e639c; + color: #ffffff; +} + +.btn-success:hover { + background: #1177bb; +} + +.btn-danger { + background: #a1260d; + color: #ffffff; +} + +.btn-danger:hover { + background: #c42e11; +} + +.btn-secondary { + background: #3e3e42; + color: #cccccc; +} + +.btn-secondary:hover { + background: #4a4a4a; +} + +.btn-small { + padding: 4px 8px; + font-size: 11px; + background: #3e3e42; + color: #cccccc; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.btn-close { + background: none; + border: none; + color: #cccccc; + font-size: 20px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: #858585; +} + +/* Evidence Panel */ +.console-evidence { + width: 300px; + background: #252526; + border-left: 1px solid #3e3e42; + overflow-y: auto; + flex-shrink: 0; +} + +.evidence-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #3e3e42; +} + +.evidence-header h3 { + font-size: 14px; + font-weight: 600; + color: #cccccc; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.evidence-list { + padding: 16px; +} + +.evidence-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + border-radius: 4px; + margin-bottom: 8px; + cursor: pointer; +} + +.evidence-item:hover { + background: #2a2d2e; +} + +.evidence-icon { + font-size: 14px; + width: 20px; + text-align: center; +} + +.evidence-name { + flex: 1; + font-size: 12px; + color: #cccccc; +} + +.evidence-action { + font-size: 11px; + color: #4ec9b0; + text-decoration: none; +} + +.evidence-action:hover { + text-decoration: underline; +} + +/* Footer - Live Feed */ +.console-footer { + height: 200px; + background: #252526; + border-top: 1px solid #3e3e42; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.footer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid #3e3e42; +} + +.footer-header h3 { + font-size: 12px; + font-weight: 600; + color: #cccccc; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.live-feed { + flex: 1; + overflow-y: auto; + padding: 8px 16px; + font-family: 'Courier New', monospace; + font-size: 11px; + line-height: 1.6; +} + +.feed-line { + margin-bottom: 4px; + white-space: pre-wrap; + word-break: break-all; +} + +.feed-line.INFO { + color: #cccccc; +} + +.feed-line.WAIT { + color: #ffa500; +} + +.feed-line.WARN { + color: #ffaa00; +} + +.feed-line.ERROR { + color: #f48771; +} + +.feed-line.EVID { + color: #4ec9b0; +} + +.feed-timestamp { + color: #858585; + margin-right: 8px; +} + +/* Status Icons */ +.status-pending { color: #858585; } +.status-running { color: #ffa500; } +.status-pass { color: #4ec9b0; } +.status-warn { color: #ffaa00; } +.status-fail { color: #f48771; } +.status-skip { color: #858585; } + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1e1e1e; +} + +::-webkit-scrollbar-thumb { + background: #3e3e42; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4a4a4a; +} + diff --git a/test-apps/android-test-app/app/src/main/assets/public/console/console.js b/test-apps/android-test-app/app/src/main/assets/public/console/console.js new file mode 100644 index 0000000..48866d5 --- /dev/null +++ b/test-apps/android-test-app/app/src/main/assets/public/console/console.js @@ -0,0 +1,685 @@ +/** + * Daily Notification Plugin Test Console + * Textual-inspired operator console for test execution + */ + +// State management +const ConsoleState = { + plan: null, + runId: null, + runState: {}, + currentPhase: null, + currentTest: null, + currentStep: null, + evidence: {}, + feed: [] +}; + +// Initialize console +async function initConsole() { + try { + // Generate run ID + ConsoleState.runId = generateRunId(); + updateHeader(); + + // Load test plan + const planResponse = await fetch('plan.json'); + ConsoleState.plan = await planResponse.json(); + + // Load saved run state + loadRunState(); + + // Render UI + renderPhaseTree(); + renderEmptyState(); + + // Setup event listeners + setupEventListeners(); + + // Setup Capacitor plugin listener (if available) + setupCapacitorListener(); + + // Add initial feed message + addFeedLine('INFO', 'console', 'Console initialized', 'Console ready'); + + } catch (error) { + console.error('Failed to initialize console:', error); + addFeedLine('ERROR', 'console', 'Initialization failed', error.message); + } +} + +// Generate run ID +function generateRunId() { + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ''); + const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, ''); + const random = Math.random().toString(36).substring(2, 6); + return `${dateStr}_${timeStr}_${random}`; +} + +// Update header +function updateHeader() { + const runIdEl = document.getElementById('run-id'); + if (runIdEl) { + runIdEl.textContent = ConsoleState.runId; + } + + // Try to get device info from Capacitor + if (window.Capacitor && window.Capacitor.Plugins.Device) { + window.Capacitor.Plugins.Device.getInfo().then(info => { + const deviceIdEl = document.getElementById('device-id'); + if (deviceIdEl) { + deviceIdEl.textContent = info.model || 'unknown'; + } + }).catch(() => { + }); + } +} + +// Load run state from localStorage +function loadRunState() { + const saved = localStorage.getItem(`runState_${ConsoleState.runId}`); + if (saved) { + try { + ConsoleState.runState = JSON.parse(saved); + } catch (e) { + console.error('Failed to parse saved run state:', e); + } + } +} + +// Save run state to localStorage +function saveRunState() { + try { + localStorage.setItem(`runState_${ConsoleState.runId}`, JSON.stringify(ConsoleState.runState)); + } catch (e) { + console.error('Failed to save run state:', e); + } +} + +// Render phase tree +function renderPhaseTree() { + const treeEl = document.getElementById('phase-tree'); + if (!treeEl || !ConsoleState.plan) return; + + treeEl.innerHTML = ''; + + ConsoleState.plan.phases.forEach(phase => { + const phaseEl = createPhaseElement(phase); + treeEl.appendChild(phaseEl); + }); +} + +// Create phase element +function createPhaseElement(phase) { + const phaseDiv = document.createElement('div'); + phaseDiv.className = 'phase-item'; + + const phaseState = getPhaseState(phase.id); + const statusIcon = getStatusIcon(phaseState.status); + const progress = getPhaseProgress(phase.id); + + const header = document.createElement('div'); + header.className = 'phase-header'; + header.innerHTML = ` + ${statusIcon} + ${phase.title} + ${progress} + `; + header.onclick = () => togglePhase(phase.id); + + phaseDiv.appendChild(header); + + // Test list (initially hidden, shown when phase expanded) + const testList = document.createElement('div'); + testList.className = 'test-list'; + testList.style.display = 'none'; + testList.id = `test-list-${phase.id}`; + + phase.tests.forEach(test => { + const testEl = createTestElement(phase.id, test); + testList.appendChild(testEl); + }); + + phaseDiv.appendChild(testList); + + return phaseDiv; +} + +// Create test element +function createTestElement(phaseId, test) { + const testDiv = document.createElement('div'); + testDiv.className = 'test-item'; + testDiv.id = `test-${phaseId}-${test.id}`; + + const testState = getTestState(phaseId, test.id); + const statusIcon = getStatusIcon(testState.status); + + testDiv.innerHTML = ` + ${statusIcon} + ${test.title} + `; + + testDiv.onclick = () => selectTest(phaseId, test.id); + + return testDiv; +} + +// Get phase state +function getPhaseState(phaseId) { + if (!ConsoleState.runState.phases) { + ConsoleState.runState.phases = {}; + } + if (!ConsoleState.runState.phases[phaseId]) { + ConsoleState.runState.phases[phaseId] = { status: 'pending' }; + } + return ConsoleState.runState.phases[phaseId]; +} + +// Get test state +function getTestState(phaseId, testId) { + if (!ConsoleState.runState.phases) { + ConsoleState.runState.phases = {}; + } + if (!ConsoleState.runState.phases[phaseId]) { + ConsoleState.runState.phases[phaseId] = { tests: {} }; + } + if (!ConsoleState.runState.phases[phaseId].tests) { + ConsoleState.runState.phases[phaseId].tests = {}; + } + if (!ConsoleState.runState.phases[phaseId].tests[testId]) { + ConsoleState.runState.phases[phaseId].tests[testId] = { status: 'pending', steps: {} }; + } + return ConsoleState.runState.phases[phaseId].tests[testId]; +} + +// Get step state +function getStepState(phaseId, testId, stepId) { + const testState = getTestState(phaseId, testId); + if (!testState.steps) { + testState.steps = {}; + } + if (!testState.steps[stepId]) { + testState.steps[stepId] = { status: 'pending' }; + } + return testState.steps[stepId]; +} + +// Get status icon +function getStatusIcon(status) { + const icons = { + 'pending': '·', + 'running': '→', + 'pass': '✓', + 'warn': '⚠', + 'fail': '✗', + 'skip': '⊘' + }; + return icons[status] || '·'; +} + +// Get phase progress +function getPhaseProgress(phaseId) { + const phase = ConsoleState.plan.phases.find(p => p.id === phaseId); + if (!phase) return '(0/0)'; + + let completed = 0; + let total = phase.tests.length; + + phase.tests.forEach(test => { + const testState = getTestState(phaseId, test.id); + if (testState.status === 'pass' || testState.status === 'warn') { + completed++; + } + }); + + return `(${completed}/${total})`; +} + +// Toggle phase expansion +function togglePhase(phaseId) { + const testList = document.getElementById(`test-list-${phaseId}`); + if (testList) { + testList.style.display = testList.style.display === 'none' ? 'block' : 'none'; + } +} + +// Select test +function selectTest(phaseId, testId) { + ConsoleState.currentPhase = phaseId; + ConsoleState.currentTest = testId; + + // Update active test highlight + document.querySelectorAll('.test-item').forEach(el => { + el.classList.remove('active'); + }); + const testEl = document.getElementById(`test-${phaseId}-${testId}`); + if (testEl) { + testEl.classList.add('active'); + } + + // Render test view + renderTestView(phaseId, testId); +} + +// Render test view +function renderTestView(phaseId, testId) { + const phase = ConsoleState.plan.phases.find(p => p.id === phaseId); + if (!phase) return; + + const test = phase.tests.find(t => t.id === testId); + if (!test) return; + + // Hide empty state + document.getElementById('empty-state').style.display = 'none'; + + // Show test header + const testHeader = document.getElementById('test-header'); + testHeader.style.display = 'block'; + document.getElementById('test-title').textContent = `${phase.title} / ${test.title}`; + document.getElementById('test-purpose').textContent = test.purpose; + + // Render step checklist + renderStepChecklist(phaseId, testId, test.steps); + + // Render current step (first step or active step) + const activeStepId = getActiveStepId(phaseId, testId, test.steps); + if (activeStepId) { + renderCurrentStep(phaseId, testId, activeStepId, test.steps); + } + + // Show sections + document.getElementById('step-checklist-section').style.display = 'block'; + document.getElementById('current-step-section').style.display = 'block'; +} + +// Render step checklist +function renderStepChecklist(phaseId, testId, steps) { + const checklistEl = document.getElementById('step-checklist'); + if (!checklistEl) return; + + checklistEl.innerHTML = ''; + + steps.forEach((step, index) => { + const stepState = getStepState(phaseId, testId, step.id); + const statusIcon = getStatusIcon(stepState.status); + + const stepEl = document.createElement('div'); + stepEl.className = 'step-item'; + if (step.id === ConsoleState.currentStep) { + stepEl.classList.add('active'); + } + + stepEl.innerHTML = ` + ${statusIcon} + ${index + 1}/${steps.length} + ${step.title} + ${step.type} + `; + + stepEl.onclick = () => { + ConsoleState.currentStep = step.id; + renderCurrentStep(phaseId, testId, step.id, steps); + renderStepChecklist(phaseId, testId, steps); // Re-render to update active + }; + + checklistEl.appendChild(stepEl); + }); +} + +// Get active step ID +function getActiveStepId(phaseId, testId, steps) { + // Find first step that's not pass + for (const step of steps) { + const stepState = getStepState(phaseId, testId, step.id); + if (stepState.status !== 'pass') { + return step.id; + } + } + // If all passed, return last step + return steps.length > 0 ? steps[steps.length - 1].id : null; +} + +// Render current step +function renderCurrentStep(phaseId, testId, stepId, steps) { + const step = steps.find(s => s.id === stepId); + if (!step) return; + + ConsoleState.currentStep = stepId; + + // Update step header + document.getElementById('step-title').textContent = step.title; + document.getElementById('step-type').textContent = step.type + (step.blocking ? ' (blocking)' : ''); + + // Update step content + document.getElementById('step-purpose').textContent = step.purpose || '—'; + document.getElementById('step-instructions').innerHTML = formatList(step.instructions || []); + document.getElementById('step-expected').innerHTML = formatList(step.expected || []); + document.getElementById('step-artifacts').innerHTML = formatList(step.artifacts || []); + + // Update step actions + const stepState = getStepState(phaseId, testId, stepId); + updateStepActions(phaseId, testId, stepId, stepState); +} + +// Format list as HTML +function formatList(items) { + if (!items || items.length === 0) return '—'; + return ''; +} + +// Update step actions +function updateStepActions(phaseId, testId, stepId, stepState) { + const btnDone = document.getElementById('btn-step-done'); + const btnFail = document.getElementById('btn-step-fail'); + + if (stepState.status === 'pass') { + btnDone.disabled = true; + btnDone.textContent = 'Done ✓'; + } else { + btnDone.disabled = false; + btnDone.textContent = 'Mark Done ✓'; + } + + if (stepState.status === 'fail') { + btnFail.disabled = true; + btnFail.textContent = 'Failed ✗'; + } else { + btnFail.disabled = false; + btnFail.textContent = 'Mark Fail ✗'; + } +} + +// Setup event listeners +function setupEventListeners() { + // Step action buttons + document.getElementById('btn-step-done').onclick = () => { + if (ConsoleState.currentPhase && ConsoleState.currentTest && ConsoleState.currentStep) { + markStepDone(ConsoleState.currentPhase, ConsoleState.currentTest, ConsoleState.currentStep); + } + }; + + document.getElementById('btn-step-fail').onclick = () => { + if (ConsoleState.currentPhase && ConsoleState.currentTest && ConsoleState.currentStep) { + markStepFail(ConsoleState.currentPhase, ConsoleState.currentTest, ConsoleState.currentStep); + } + }; + + document.getElementById('btn-step-notes').onclick = () => { + if (ConsoleState.currentPhase && ConsoleState.currentTest && ConsoleState.currentStep) { + showStepNotes(); + } + }; + + // Evidence panel + document.getElementById('btn-evidence-close').onclick = () => { + document.getElementById('evidence-panel').style.display = 'none'; + }; + + // Feed clear + document.getElementById('btn-feed-clear').onclick = () => { + ConsoleState.feed = []; + renderLiveFeed(); + }; +} + +// Mark step done +function markStepDone(phaseId, testId, stepId) { + const stepState = getStepState(phaseId, testId, stepId); + stepState.status = 'pass'; + stepState.completedAt = new Date().toISOString(); + + saveRunState(); + updateTestStatus(phaseId, testId); + renderTestView(phaseId, testId); + + addFeedLine('INFO', stepId, 'Step completed', `Step marked as done`); +} + +// Mark step fail +function markStepFail(phaseId, testId, stepId) { + const stepState = getStepState(phaseId, testId, stepId); + stepState.status = 'fail'; + stepState.failedAt = new Date().toISOString(); + + saveRunState(); + updateTestStatus(phaseId, testId); + renderTestView(phaseId, testId); + + addFeedLine('ERROR', stepId, 'Step failed', `Step marked as failed`); +} + +// Update test status based on steps +function updateTestStatus(phaseId, testId) { + const phase = ConsoleState.plan.phases.find(p => p.id === phaseId); + if (!phase) return; + + const test = phase.tests.find(t => t.id === testId); + if (!test) return; + + const testState = getTestState(phaseId, testId); + let hasFail = false; + let hasWarn = false; + let allPass = true; + + test.steps.forEach(step => { + const stepState = getStepState(phaseId, testId, step.id); + if (stepState.status === 'fail') { + hasFail = true; + allPass = false; + } else if (stepState.status === 'warn') { + hasWarn = true; + allPass = false; + } else if (stepState.status !== 'pass') { + allPass = false; + } + }); + + if (hasFail) { + testState.status = 'fail'; + } else if (hasWarn) { + testState.status = 'warn'; + } else if (allPass) { + testState.status = 'pass'; + } else { + testState.status = 'running'; + } + + saveRunState(); + renderPhaseTree(); // Update phase tree to reflect new status +} + +// Show step notes +function showStepNotes() { + const stepState = getStepState(ConsoleState.currentPhase, ConsoleState.currentTest, ConsoleState.currentStep); + const notes = prompt('Add notes for this step:', stepState.notes || ''); + if (notes !== null) { + stepState.notes = notes; + saveRunState(); + } +} + +// Render empty state +function renderEmptyState() { + document.getElementById('empty-state').style.display = 'block'; + document.getElementById('test-header').style.display = 'none'; + document.getElementById('step-checklist-section').style.display = 'none'; + document.getElementById('current-step-section').style.display = 'none'; +} + +// Add feed line +function addFeedLine(level, source, category, message) { + const timestamp = new Date().toLocaleTimeString(); + const line = { + timestamp, + level, + source, + category, + message + }; + + ConsoleState.feed.push(line); + + // Keep feed to last 100 lines + if (ConsoleState.feed.length > 100) { + ConsoleState.feed.shift(); + } + + renderLiveFeed(); +} + +// Render live feed +function renderLiveFeed() { + const feedEl = document.getElementById('live-feed'); + if (!feedEl) return; + + feedEl.innerHTML = ConsoleState.feed.map(line => { + return `
+ ${line.timestamp} + ${line.level} + ${line.source} + ${line.message} +
`; + }).join(''); + + // Auto-scroll to bottom + feedEl.scrollTop = feedEl.scrollHeight; +} + +// Setup Capacitor listener for test events +function setupCapacitorListener() { + if (window.Capacitor && window.Capacitor.Plugins.TestEvents) { + window.Capacitor.Plugins.TestEvents.addListener('testEvent', (event) => { + handleTestEvent(event.payload); + }); + } else { + // Fallback: poll for events file (if scripts write to /sdcard/Download/dnp-events.jsonl) + // This is a simple fallback - in production, use broadcast + console.log('TestEvents plugin not available, using fallback polling'); + } +} + +// Handle test event from scripts +function handleTestEvent(eventData) { + try { + const event = typeof eventData === 'string' ? JSON.parse(eventData) : eventData; + + // Update step/test status based on event + if (event.phaseId && event.testId && event.stepId) { + const stepState = getStepState(event.phaseId, event.testId, event.stepId); + + if (event.type === 'step_start') { + stepState.status = 'running'; + stepState.startedAt = event.ts; + } else if (event.type === 'step_pass') { + stepState.status = 'pass'; + stepState.completedAt = event.ts; + } else if (event.type === 'step_warn') { + stepState.status = 'warn'; + stepState.completedAt = event.ts; + } else if (event.type === 'step_fail') { + stepState.status = 'fail'; + stepState.failedAt = event.ts; + } + + updateTestStatus(event.phaseId, event.testId); + + // Re-render if this is the current test + if (ConsoleState.currentPhase === event.phaseId && ConsoleState.currentTest === event.testId) { + const phase = ConsoleState.plan.phases.find(p => p.id === event.phaseId); + if (phase) { + const test = phase.tests.find(t => t.id === event.testId); + if (test) { + renderTestView(event.phaseId, event.testId); + } + } + } + } + + // Add to feed + addFeedLine(event.level || 'INFO', event.stepId || event.testId || 'system', event.type || 'event', event.message || JSON.stringify(event)); + + // Handle artifacts + if (event.type === 'artifact' && event.artifact) { + addEvidence(event.phaseId, event.testId, event.artifact); + } + + saveRunState(); + + } catch (error) { + console.error('Failed to handle test event:', error); + addFeedLine('ERROR', 'system', 'event_handler', `Failed to process event: ${error.message}`); + } +} + +// Add evidence +function addEvidence(phaseId, testId, artifact) { + if (!ConsoleState.evidence[phaseId]) { + ConsoleState.evidence[phaseId] = {}; + } + if (!ConsoleState.evidence[phaseId][testId]) { + ConsoleState.evidence[phaseId][testId] = []; + } + + ConsoleState.evidence[phaseId][testId].push(artifact); + renderEvidence(phaseId, testId); +} + +// Render evidence +function renderEvidence(phaseId, testId) { + if (ConsoleState.currentPhase !== phaseId || ConsoleState.currentTest !== testId) { + return; // Not viewing this test + } + + const evidenceList = document.getElementById('evidence-list'); + if (!evidenceList) return; + + const artifacts = ConsoleState.evidence[phaseId]?.[testId] || []; + evidenceList.innerHTML = ''; + + if (artifacts.length === 0) { + evidenceList.innerHTML = '
No evidence captured yet
'; + return; + } + + artifacts.forEach(artifact => { + const item = document.createElement('div'); + item.className = 'evidence-item'; + + const icon = getArtifactIcon(artifact.kind); + const name = artifact.name || artifact.path || 'Unknown'; + + item.innerHTML = ` + ${icon} + ${name} + view + `; + + evidenceList.appendChild(item); + }); + + // Show evidence panel + document.getElementById('evidence-panel').style.display = 'block'; +} + +// Get artifact icon +function getArtifactIcon(kind) { + const icons = { + 'alarms': '📋', + 'logs': '📄', + 'screen': '📷', + 'notes': '📝' + }; + return icons[kind] || '📎'; +} + +// View evidence (placeholder) +function viewEvidence(path) { + addFeedLine('INFO', 'evidence', 'view', `Viewing evidence: ${path}`); + // In a real implementation, this would open the file or show it in a modal + alert(`Evidence file: ${path}\n\nIn production, this would open the file viewer.`); +} + +// Initialize on load +document.addEventListener('DOMContentLoaded', initConsole); + diff --git a/test-apps/android-test-app/app/src/main/assets/public/console/index.html b/test-apps/android-test-app/app/src/main/assets/public/console/index.html new file mode 100644 index 0000000..58c8b5d --- /dev/null +++ b/test-apps/android-test-app/app/src/main/assets/public/console/index.html @@ -0,0 +1,114 @@ + + + + + + + + + Daily Notification Plugin — Test Console + + + +
+ +
+
+

Daily Notification Plugin — Test Console

+
+
+ Device: + Run: + Mode: ADVISORY + Strictness: SOFT +
+
+ + +
+ + + + +
+ + + + + + + + + + +
+

Select a test from the left sidebar to begin.

+
+
+ + + +
+ + + +
+ + + + + diff --git a/test-apps/android-test-app/app/src/main/assets/public/console/plan.json b/test-apps/android-test-app/app/src/main/assets/public/console/plan.json new file mode 100644 index 0000000..16364ed --- /dev/null +++ b/test-apps/android-test-app/app/src/main/assets/public/console/plan.json @@ -0,0 +1,793 @@ +{ + "version": "testplan.v1", + "app": "Daily Notification Plugin", + "phases": [ + { + "id": "phase1", + "title": "Daily Rollover & Recovery", + "tests": [ + { + "id": "phase1_setup", + "title": "Setup: Pre-flight Checks", + "purpose": "Verify ADB connection, build app, install, check permissions, and configure plugin.", + "steps": [ + { + "id": "p1_setup_s1", + "title": "Preflight: ADB Connection", + "type": "AUTO", + "blocking": true, + "instructions": ["Verify ADB device connected", "Check emulator boot status"], + "expected": ["ADB device in 'device' state", "Emulator boot completed"], + "artifacts": [] + }, + { + "id": "p1_setup_s2", + "title": "Build App", + "type": "AUTO", + "blocking": true, + "instructions": ["Run ./gradlew :app:assembleDebug"], + "expected": ["APK built successfully", "APK found at expected path"], + "artifacts": [] + }, + { + "id": "p1_setup_s3", + "title": "Install App", + "type": "AUTO", + "blocking": true, + "instructions": ["Uninstall existing app (if present)", "Install new APK", "Verify installation"], + "expected": ["App uninstalled (or not present)", "APK installed successfully", "App in package list"], + "artifacts": [] + }, + { + "id": "p1_setup_s4", + "title": "Launch App & Check Permissions", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app main activity", + "In app UI, verify:", + " • Notifications: ✅ Granted", + " • Exact Alarms: ✅ Granted", + "If not granted, click 'Request Permissions'" + ], + "expected": ["App launched", "Permissions granted"], + "artifacts": ["screenshots/setup_permissions.png"] + }, + { + "id": "p1_setup_s5", + "title": "Configure Plugin", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "In app UI, click 'Configure Plugin'", + "Wait until both show ✅:", + " • ⚙️ Plugin Settings: ✅ Configured", + " • 🔌 Native Fetcher: ✅ Configured" + ], + "expected": ["Plugin configured", "Native fetcher configured"], + "artifacts": ["screenshots/setup_configured.png"] + } + ] + }, + { + "id": "phase1_smoke", + "title": "Smoke: Schedule One + Verify Pending", + "purpose": "Validate end-to-end scheduling path and ensure exactly one plugin alarm exists.", + "steps": [ + { + "id": "p1_smoke_s1", + "title": "Preflight", + "type": "AUTO", + "blocking": true, + "instructions": ["Check ADB connection", "Verify app installed"], + "expected": ["ADB connected", "App installed"], + "artifacts": [] + }, + { + "id": "p1_smoke_s2", + "title": "Build + Install App", + "type": "AUTO", + "blocking": true, + "instructions": ["Build APK", "Install APK"], + "expected": ["Build successful", "Install successful"], + "artifacts": [] + }, + { + "id": "p1_smoke_s3", + "title": "Schedule One Notification", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app", + "Click 'Test Notification' to schedule for a few minutes in the future" + ], + "expected": ["Notification scheduled", "App shows pending count > 0"], + "artifacts": ["alarms/smoke_after_schedule.txt", "screenshots/smoke_after_schedule.png"] + }, + { + "id": "p1_smoke_s4", + "title": "Verify Exactly 1 Plugin Alarm Exists", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check dumpsys alarm for plugin alarms"], + "expected": ["Exactly 1 plugin NOTIFICATION alarm found", "No duplicate alarms"], + "artifacts": ["alarms/smoke_verification.txt"] + }, + { + "id": "p1_smoke_s5", + "title": "Capture Evidence", + "type": "EVIDENCE", + "blocking": false, + "instructions": ["Capture alarms dump", "Capture logcat", "Capture screenshot"], + "expected": ["Evidence files created"], + "artifacts": ["alarms/smoke_final.txt", "logs/smoke_final_logcat.txt", "screenshots/smoke_final.png"] + }, + { + "id": "p1_smoke_s6", + "title": "Verdict + Export Summary", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded (PASS/WARN/FAIL)"], + "artifacts": ["summary.json", "summary.md"] + } + ] + }, + { + "id": "phase1_test0", + "title": "Daily Rollover Verification", + "purpose": "Verify that after a notification fires, the next day's schedule is correctly computed and only ONE alarm exists.", + "steps": [ + { + "id": "p1_t0_s1", + "title": "Capture Initial State", + "type": "EVIDENCE", + "blocking": false, + "instructions": ["Capture alarms dump", "Capture logcat"], + "expected": ["Evidence files created"], + "artifacts": ["alarms/test0_initial.txt", "logs/test0_initial_logcat.txt"] + }, + { + "id": "p1_t0_s2", + "title": "Schedule Test Notification", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app", + "Schedule a daily notification for a time very soon (e.g., 1-2 minutes from now)" + ], + "expected": ["Notification scheduled", "Exactly 1 alarm exists"], + "artifacts": ["alarms/test0_after_schedule.txt", "screenshots/test0_after_schedule.png"] + }, + { + "id": "p1_t0_s3", + "title": "Advance Time Past Midnight", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Settings → System → Date & time", + "Disable auto time", + "Set time to 23:59, then to 00:01" + ], + "expected": ["Time advanced past midnight", "Rollover logic triggered"], + "artifacts": [] + }, + { + "id": "p1_t0_s4", + "title": "Observe Rollover Logs", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check logcat for rollover start/end messages"], + "expected": ["Rollover started log found", "Rollover completed log found"], + "artifacts": ["logs/test0_rollover_logcat.txt"] + }, + { + "id": "p1_t0_s5", + "title": "Capture Post-Rollover Evidence", + "type": "EVIDENCE", + "blocking": false, + "instructions": ["Capture alarms dump", "Capture logcat", "Capture screenshot"], + "expected": ["Evidence files created"], + "artifacts": ["alarms/test0_after_rollover.txt", "logs/test0_after_rollover_logcat.txt", "screenshots/test0_after_rollover.png"] + }, + { + "id": "p1_t0_s6", + "title": "Verify Schedule State", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify next-day schedule"], + "expected": ["Exactly 1 alarm exists", "Alarm time is for tomorrow (24h later)", "No duplicate alarms"], + "artifacts": [] + }, + { + "id": "p1_t0_s7", + "title": "Verdict", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded (PASS/WARN/FAIL)"], + "artifacts": [] + }, + { + "id": "p1_t0_s8", + "title": "Export Summary", + "type": "EVIDENCE", + "blocking": false, + "instructions": ["Generate summary files"], + "expected": ["Summary files created"], + "artifacts": ["summary.json", "summary.md"] + } + ] + }, + { + "id": "phase1_test1", + "title": "Force-Stop Recovery - Database Restoration", + "purpose": "Verify that after force-stop (which clears alarms), recovery uses the database to rebuild alarms on app relaunch.", + "steps": [ + { + "id": "p1_t1_s1", + "title": "Clean Start - Verify No Lingering Alarms", + "type": "AUTO", + "blocking": true, + "instructions": ["Check for existing plugin alarms", "Reset app state if needed"], + "expected": ["No existing plugin alarms", "Clean state confirmed"], + "artifacts": ["alarms/test1_initial.txt"] + }, + { + "id": "p1_t1_s2", + "title": "Schedule Notification", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app", + "Schedule at least one future notification" + ], + "expected": ["Notification scheduled", "Alarm exists in system"], + "artifacts": ["alarms/test1_before_force_stop.txt"] + }, + { + "id": "p1_t1_s3", + "title": "Force Stop App", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Settings → Apps → Daily Notification Test", + "Force stop the app" + ], + "expected": ["App force stopped", "Alarms cleared (on most devices)"], + "artifacts": ["alarms/test1_after_force_stop.txt"] + }, + { + "id": "p1_t1_s4", + "title": "Relaunch App & Verify Recovery", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Launch app", + "Check logs for recovery scenario detection", + "Verify alarms recreated from database" + ], + "expected": ["FORCE_STOP scenario detected", "Alarms recreated", "Recovery successful"], + "artifacts": ["logs/test1_recovery_logcat.txt", "alarms/test1_after_recovery.txt"] + }, + { + "id": "p1_t1_s5", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase1_test2", + "title": "Schedule Update Verification", + "purpose": "Verify schedule updates work correctly and preserve one-per-day semantics.", + "steps": [ + { + "id": "p1_t2_s1", + "title": "Schedule Initial Notification", + "type": "MANUAL", + "blocking": true, + "instructions": ["Launch app", "Schedule notification for time A"], + "expected": ["Notification scheduled", "1 alarm exists"], + "artifacts": ["alarms/test2_initial.txt"] + }, + { + "id": "p1_t2_s2", + "title": "Update Schedule", + "type": "MANUAL", + "blocking": true, + "instructions": ["Update notification to time B"], + "expected": ["Schedule updated", "Old alarm cancelled", "New alarm scheduled"], + "artifacts": ["alarms/test2_after_update.txt"] + }, + { + "id": "p1_t2_s3", + "title": "Verify One-Per-Day Semantics", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify only one alarm exists"], + "expected": ["Exactly 1 alarm exists", "No duplicate alarms"], + "artifacts": [] + }, + { + "id": "p1_t2_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase1_test3", + "title": "Recovery Timeout", + "purpose": "Verify recovery timeout behavior when alarms cannot be recreated.", + "steps": [ + { + "id": "p1_t3_s1", + "title": "Setup: Schedule Notification", + "type": "MANUAL", + "blocking": true, + "instructions": ["Launch app", "Schedule notification"], + "expected": ["Notification scheduled"], + "artifacts": [] + }, + { + "id": "p1_t3_s2", + "title": "Force Stop & Simulate Timeout", + "type": "MANUAL", + "blocking": true, + "instructions": ["Force stop app", "Wait for timeout period"], + "expected": ["Timeout detected", "Recovery attempted"], + "artifacts": ["logs/test3_timeout_logcat.txt"] + }, + { + "id": "p1_t3_s3", + "title": "Verify Timeout Handling", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check logs for timeout messages", "Verify fallback behavior"], + "expected": ["Timeout logged", "Fallback triggered"], + "artifacts": [] + }, + { + "id": "p1_t3_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase1_test4", + "title": "Invalid Data Handling", + "purpose": "Verify plugin handles invalid/corrupted data gracefully.", + "steps": [ + { + "id": "p1_t4_s1", + "title": "Inject Invalid Data", + "type": "MANUAL", + "blocking": true, + "instructions": ["Corrupt database or preferences", "Launch app"], + "expected": ["Invalid data detected"], + "artifacts": [] + }, + { + "id": "p1_t4_s2", + "title": "Verify Error Handling", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check logs for error messages", "Verify app doesn't crash"], + "expected": ["Error logged", "App continues running", "Recovery attempted"], + "artifacts": ["logs/test4_error_logcat.txt"] + }, + { + "id": "p1_t4_s3", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + } + ] + }, + { + "id": "phase2", + "title": "Force Stop Recovery", + "tests": [ + { + "id": "phase2_test1", + "title": "Force Stop – Alarms Cleared", + "purpose": "Verify force stop detection and alarm rescheduling when alarms are cleared.", + "steps": [ + { + "id": "p2_t1_s1", + "title": "Launch App & Check Plugin Status", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app", + "Verify plugin configured", + "Schedule at least one future notification" + ], + "expected": ["Plugin configured", "Notification scheduled"], + "artifacts": ["alarms/phase2_test1_initial.txt", "logs/phase2_test1_initial_logcat.txt"] + }, + { + "id": "p2_t1_s2", + "title": "Verify Alarms Scheduled", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify exactly 1 plugin alarm"], + "expected": ["1 plugin alarm exists", "Alarm details visible"], + "artifacts": ["alarms/phase2_test1_before_force_stop.txt"] + }, + { + "id": "p2_t1_s3", + "title": "Force Stop App", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Settings → Apps → Daily Notification Test", + "Force stop the app" + ], + "expected": ["App force stopped", "Alarms cleared (on most devices)"], + "artifacts": ["alarms/phase2_test1_after_force_stop.txt"] + }, + { + "id": "p2_t1_s4", + "title": "Check Alarms After Force Stop", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify alarms cleared"], + "expected": ["0 plugin alarms (or alarms cleared)", "System confirms force stop"], + "artifacts": [] + }, + { + "id": "p2_t1_s5", + "title": "Relaunch App & Verify Recovery", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Launch app", + "Check logs for FORCE_STOP scenario", + "Verify alarms recreated" + ], + "expected": ["FORCE_STOP scenario detected", "Alarms recreated", "Recovery successful"], + "artifacts": ["logs/phase2_test1_recovery_logcat.txt", "alarms/phase2_test1_after_recovery.txt"] + }, + { + "id": "p2_t1_s6", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase2_test2", + "title": "Force Stop – Alarms Intact", + "purpose": "Verify force stop detection when alarms remain intact (some devices don't clear alarms on force stop).", + "steps": [ + { + "id": "p2_t2_s1", + "title": "Schedule Notification", + "type": "MANUAL", + "blocking": true, + "instructions": ["Launch app", "Schedule notification"], + "expected": ["Notification scheduled"], + "artifacts": ["alarms/phase2_test2_initial.txt"] + }, + { + "id": "p2_t2_s2", + "title": "Force Stop App", + "type": "MANUAL", + "blocking": true, + "instructions": ["Force stop app"], + "expected": ["App force stopped"], + "artifacts": ["alarms/phase2_test2_after_force_stop.txt"] + }, + { + "id": "p2_t2_s3", + "title": "Verify Alarms Intact", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify alarms still exist"], + "expected": ["Alarms still exist", "No recovery needed"], + "artifacts": [] + }, + { + "id": "p2_t2_s4", + "title": "Relaunch & Verify Behavior", + "type": "ASSERT", + "blocking": true, + "instructions": ["Launch app", "Check logs", "Verify no duplicate alarms"], + "expected": ["No duplicate alarms", "Correct behavior"], + "artifacts": ["logs/phase2_test2_logcat.txt"] + }, + { + "id": "p2_t2_s5", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase2_test3", + "title": "First Launch / No Schedules", + "purpose": "Verify app behavior on first launch when no schedules exist.", + "steps": [ + { + "id": "p2_t3_s1", + "title": "Fresh Install", + "type": "AUTO", + "blocking": true, + "instructions": ["Uninstall app", "Install fresh APK"], + "expected": ["App installed", "No existing data"], + "artifacts": [] + }, + { + "id": "p2_t3_s2", + "title": "First Launch", + "type": "MANUAL", + "blocking": true, + "instructions": ["Launch app for first time", "Don't schedule anything"], + "expected": ["App launches", "No crashes"], + "artifacts": ["logs/phase2_test3_first_launch_logcat.txt"] + }, + { + "id": "p2_t3_s3", + "title": "Verify No Alarms", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify no alarms scheduled"], + "expected": ["0 plugin alarms", "No errors"], + "artifacts": ["alarms/phase2_test3_no_schedules.txt"] + }, + { + "id": "p2_t3_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + } + ] + }, + { + "id": "phase3", + "title": "Boot Recovery", + "tests": [ + { + "id": "phase3_test1", + "title": "Boot with Future Alarms", + "purpose": "Verify alarms are recreated on boot when schedules have future run times.", + "steps": [ + { + "id": "p3_t1_s1", + "title": "Schedule Notification", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Launch app", + "Verify plugin configured", + "Schedule at least one future notification" + ], + "expected": ["Notification scheduled", "1 alarm exists"], + "artifacts": ["alarms/phase3_test1_initial.txt", "logs/phase3_test1_initial_logcat.txt"] + }, + { + "id": "p3_t1_s2", + "title": "Verify Alarms Scheduled", + "type": "ASSERT", + "blocking": true, + "instructions": ["Check alarm count", "Verify alarm details"], + "expected": ["1 plugin alarm exists", "Alarm time is in future"], + "artifacts": ["alarms/phase3_test1_before_reboot.txt"] + }, + { + "id": "p3_t1_s3", + "title": "Reboot Emulator", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Reboot emulator (adb reboot)", + "Wait 30-60 seconds for boot to complete" + ], + "expected": ["Emulator rebooted", "Boot completed"], + "artifacts": [] + }, + { + "id": "p3_t1_s4", + "title": "Verify Boot Recovery", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Check logs for boot recovery", + "Verify alarms recreated", + "Check alarm count" + ], + "expected": ["BOOT scenario detected", "Alarms recreated", "1 alarm exists"], + "artifacts": ["logs/phase3_test1_boot_recovery_logcat.txt", "alarms/phase3_test1_after_boot.txt"] + }, + { + "id": "p3_t1_s5", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase3_test2", + "title": "Boot with Past Alarms (Missed Alarms)", + "purpose": "Verify boot recovery handles missed alarms (alarms that should have fired while device was off).", + "steps": [ + { + "id": "p3_t2_s1", + "title": "Schedule Notification in Past", + "type": "MANUAL", + "blocking": true, + "instructions": [ + "Schedule notification for time in past", + "Or schedule for future, then advance clock past alarm time" + ], + "expected": ["Alarm time is in past"], + "artifacts": ["alarms/phase3_test2_initial.txt"] + }, + { + "id": "p3_t2_s2", + "title": "Reboot Emulator", + "type": "MANUAL", + "blocking": true, + "instructions": ["Reboot emulator", "Wait for boot"], + "expected": ["Emulator rebooted"], + "artifacts": [] + }, + { + "id": "p3_t2_s3", + "title": "Verify Missed Alarm Handling", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Check logs for missed alarm detection", + "Verify next-day schedule created" + ], + "expected": ["Missed alarm detected", "Next-day schedule created", "Recovery successful"], + "artifacts": ["logs/phase3_test2_missed_alarm_logcat.txt"] + }, + { + "id": "p3_t2_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase3_test3", + "title": "Boot with No Schedules", + "purpose": "Verify boot recovery behavior when no schedules exist.", + "steps": [ + { + "id": "p3_t3_s1", + "title": "Fresh Install (No Schedules)", + "type": "AUTO", + "blocking": true, + "instructions": ["Uninstall app", "Install fresh APK", "Don't schedule anything"], + "expected": ["App installed", "No schedules"], + "artifacts": [] + }, + { + "id": "p3_t3_s2", + "title": "Reboot Emulator", + "type": "MANUAL", + "blocking": true, + "instructions": ["Reboot emulator", "Wait for boot"], + "expected": ["Emulator rebooted"], + "artifacts": [] + }, + { + "id": "p3_t3_s3", + "title": "Verify Boot Recovery (No Action)", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Check logs for boot recovery", + "Verify no alarms created", + "Verify no errors" + ], + "expected": ["Boot recovery detected", "No alarms created (correct)", "No errors"], + "artifacts": ["logs/phase3_test3_boot_no_schedules_logcat.txt"] + }, + { + "id": "p3_t3_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + }, + { + "id": "phase3_test4", + "title": "Silent Boot Recovery (Don't Open App)", + "purpose": "Verify boot recovery works without opening the app (boot receiver handles recovery).", + "steps": [ + { + "id": "p3_t4_s1", + "title": "Schedule Notification", + "type": "MANUAL", + "blocking": true, + "instructions": ["Launch app", "Schedule notification"], + "expected": ["Notification scheduled"], + "artifacts": ["alarms/phase3_test4_initial.txt"] + }, + { + "id": "p3_t4_s2", + "title": "Reboot Emulator", + "type": "MANUAL", + "blocking": true, + "instructions": ["Reboot emulator", "Wait for boot", "DO NOT open app"], + "expected": ["Emulator rebooted", "App not launched"], + "artifacts": [] + }, + { + "id": "p3_t4_s3", + "title": "Verify Silent Recovery", + "type": "ASSERT", + "blocking": true, + "instructions": [ + "Check logs for boot receiver activity", + "Verify alarms recreated without app launch", + "Wait 10-15 seconds for recovery" + ], + "expected": ["Boot receiver triggered", "Alarms recreated", "Recovery successful"], + "artifacts": ["logs/phase3_test4_silent_recovery_logcat.txt", "alarms/phase3_test4_after_silent_boot.txt"] + }, + { + "id": "p3_t4_s4", + "title": "Verdict + Export", + "type": "DECISION", + "blocking": true, + "instructions": ["Review evidence", "Record verdict"], + "expected": ["Verdict recorded"], + "artifacts": ["summary.json"] + } + ] + } + ] + } + ] +} + diff --git a/test-apps/android-test-app/app/src/main/assets/public/index.html b/test-apps/android-test-app/app/src/main/assets/public/index.html index 5f60ee0..0f4db95 100644 --- a/test-apps/android-test-app/app/src/main/assets/public/index.html +++ b/test-apps/android-test-app/app/src/main/assets/public/index.html @@ -162,6 +162,25 @@ } } + // Format date/time with seconds normalized to :00 + function formatDateTimeNormalized(timestamp) { + if (!timestamp || timestamp === 0) return 'None scheduled'; + const date = new Date(timestamp); + // Normalize seconds to :00 + date.setSeconds(0, 0); + // Format as: MM/DD/YYYY, HH:MM:00 AM/PM + const options = { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }; + return date.toLocaleString('en-US', options); + } + function loadPluginStatus() { console.log('loadPluginStatus called'); const pluginStatusContent = document.getElementById('pluginStatusContent'); @@ -175,7 +194,7 @@ } window.DailyNotification.getNotificationStatus() .then(result => { - const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled'; + const nextTime = formatDateTimeNormalized(result.nextNotificationTime); const hasSchedules = result.isEnabled || (result.pending && result.pending > 0); const statusIcon = hasSchedules ? '✅' : '⏸️'; pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}
@@ -233,8 +252,11 @@ priority: 'high' }) .then(() => { - const prefetchTimeReadable = prefetchTime.toLocaleTimeString(); - const notificationTimeReadable = notificationTime.toLocaleTimeString(); + // Normalize seconds to :00 for display + prefetchTime.setSeconds(0, 0); + notificationTime.setSeconds(0, 0); + const prefetchTimeReadable = prefetchTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }); + const notificationTimeReadable = notificationTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }); status.innerHTML = '✅ Notification scheduled!
' + '📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')
' + '🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')

' + @@ -417,12 +439,16 @@ } } - // Check for notification delivery periodically + // Track last known nextNotificationTime to detect changes + let lastKnownNextNotificationTime = null; + + // Check for notification delivery and status updates periodically function checkNotificationDelivery() { if (!window.DailyNotification) return; window.DailyNotification.getNotificationStatus() .then(result => { + // Check for notification delivery if (result.lastNotificationTime) { const lastTime = new Date(result.lastNotificationTime); const now = new Date(); @@ -435,15 +461,37 @@ if (indicator && timeSpan) { indicator.style.display = 'block'; - timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`; + // Normalize seconds to :00 + lastTime.setSeconds(0, 0); + timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true })}`; // Hide after 30 seconds setTimeout(() => { indicator.style.display = 'none'; }, 30000); + + // Force immediate refresh when notification is received (rollover may have occurred) + setTimeout(() => { + loadPluginStatus(); + }, 1000); // Wait 1 second for rollover to complete } } } + + // Detect if nextNotificationTime changed (rollover occurred) + const currentNextTime = result.nextNotificationTime; + if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) { + if (lastKnownNextNotificationTime !== null) { + console.log('Next notification time changed - rollover detected!'); + // Force immediate refresh + loadPluginStatus(); + } + lastKnownNextNotificationTime = currentNextTime; + } + + // Auto-refresh plugin status periodically to show updated next notification time after rollover + // This ensures the UI updates when the plugin reschedules the notification + loadPluginStatus(); }) .catch(error => { // Silently fail - this is just for visual feedback @@ -459,8 +507,19 @@ loadPermissionStatus(); loadChannelStatus(); - // Check for notification delivery every 5 seconds - setInterval(checkNotificationDelivery, 5000); + // Initialize last known next notification time + if (window.DailyNotification) { + window.DailyNotification.getNotificationStatus() + .then(result => { + lastKnownNextNotificationTime = result.nextNotificationTime; + console.log('Initialized nextNotificationTime:', lastKnownNextNotificationTime); + }) + .catch(() => {}); + } + + // Check for notification delivery and status updates every 3 seconds (more frequent) + // This ensures UI updates quickly when rollover occurs + setInterval(checkNotificationDelivery, 3000); }, 500); }); diff --git a/test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/TestEventsPlugin.java b/test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/TestEventsPlugin.java new file mode 100644 index 0000000..79aec91 --- /dev/null +++ b/test-apps/android-test-app/app/src/main/java/com/timesafari/dailynotification/TestEventsPlugin.java @@ -0,0 +1,91 @@ +package com.timesafari.dailynotification; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * TestEventsPlugin - Receives test events from shell scripts via ADB broadcasts + * + * This plugin allows test scripts (test-phase*.sh) to send structured events + * to the test console UI in real-time via Android broadcast intents. + * + * Usage from shell: + * adb shell am broadcast \ + * -a com.timesafari.dailynotification.TEST_EVENT \ + * --es payload '{"version":"testevent.v1","ts":"...","type":"step_start",...}' + */ +@CapacitorPlugin(name = "TestEvents") +public class TestEventsPlugin extends Plugin { + + private static final String TAG = "TestEventsPlugin"; + private static final String BROADCAST_ACTION = "com.timesafari.dailynotification.TEST_EVENT"; + private static final String EXTRA_PAYLOAD = "payload"; + + private BroadcastReceiver testEventReceiver; + + @Override + public void load() { + super.load(); + + // Register broadcast receiver + testEventReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BROADCAST_ACTION.equals(intent.getAction())) { + String payload = intent.getStringExtra(EXTRA_PAYLOAD); + if (payload != null) { + Log.d(TAG, "Received test event: " + payload); + notifyListeners("testEvent", new JSObject().put("payload", payload)); + } + } + } + }; + + IntentFilter filter = new IntentFilter(BROADCAST_ACTION); + getContext().registerReceiver(testEventReceiver, filter); + + Log.d(TAG, "TestEventsPlugin loaded, broadcast receiver registered"); + } + + @Override + public void handleOnDestroy() { + super.handleOnDestroy(); + + // Unregister receiver + if (testEventReceiver != null) { + try { + getContext().unregisterReceiver(testEventReceiver); + Log.d(TAG, "TestEventsPlugin unregistered"); + } catch (IllegalArgumentException e) { + // Receiver was not registered, ignore + Log.w(TAG, "Receiver was not registered: " + e.getMessage()); + } + } + } + + /** + * Plugin method to manually trigger an event (for testing) + */ + @PluginMethod + public void emitEvent(PluginCall call) { + String payload = call.getString("payload"); + if (payload == null) { + call.reject("Missing payload parameter"); + return; + } + + Log.d(TAG, "Manually emitting test event: " + payload); + notifyListeners("testEvent", new JSObject().put("payload", payload)); + call.resolve(); + } +} + diff --git a/test-apps/android-test-app/docs/CONSOLE-REMAINING-WORK.md b/test-apps/android-test-app/docs/CONSOLE-REMAINING-WORK.md new file mode 100644 index 0000000..9097720 --- /dev/null +++ b/test-apps/android-test-app/docs/CONSOLE-REMAINING-WORK.md @@ -0,0 +1,166 @@ +# Console Implementation - Remaining Work + +## ✅ Completed + +1. **Test Plan JSON** (`plan.json`) - All phases/tests/steps defined +2. **Console UI** (`console/index.html`, `console.js`, `console.css`) - Full Textual-style interface +3. **TestEventsPlugin** - Android plugin to receive ADB broadcasts +4. **Event System** - Library functions emit events (`ui_prompt`, `capture_*`, `verdict_*`) +5. **Helper Functions** - `set_test_context()`, `step_start()`, `step_pass()`, `step_warn()`, `step_fail()` +6. **Example Wiring** - Test 0 and partial Test 1 in `test-phase1.sh` demonstrate pattern +7. **Plugin Registration** - `TestEventsPlugin` added to `capacitor.plugins.json` +8. **Documentation** - Usage guide created + +## 🔧 Critical Fix Applied + +✅ **TestEventsPlugin Registration** - Added to `capacitor.plugins.json` (required for plugin to work) + +## 📋 Remaining Work + +### 1. Complete Wiring of test-phase1.sh + +**Status**: Partially complete (Test 0 done, Test 1 started, Tests 2-4 not wired) + +**Pattern to apply**: +```bash +# At test start +set_test_context "phase1" "phase1_testX" "" + +# For each step +set_test_context "phase1" "phase1_testX" "p1_tX_sY" +step_start "p1_tX_sY" "Step description" +# ... do work ... +step_pass "p1_tX_sY" "Step completed" +``` + +**Tests to wire**: +- ✅ Test 0: Daily Rollover Verification (complete) +- ⚠️ Test 1: Force-Stop Recovery (partially done, needs completion) +- ❌ Test 2: Schedule Update Verification (not wired) +- ❌ Test 3: Recovery Timeout (not wired) +- ❌ Test 4: Invalid Data Handling (not wired) + +**Step IDs from plan.json**: +- Test 1: `p1_t1_s1` through `p1_t1_s5` +- Test 2: `p1_t2_s1` through `p1_t2_s4` +- Test 3: `p1_t3_s1` through `p1_t3_s2` +- Test 4: `p1_t4_s1` through `p1_t4_s4` + +### 2. Wire test-phase2.sh + +**Status**: Not started + +**Tests to wire**: +- Test 1: Force Stop – Alarms Cleared (`phase2_test1`) +- Test 2: Force Stop – Alarms Intact (`phase2_test2`) +- Test 3: First Launch / No Schedules (`phase2_test3`) + +**Step IDs from plan.json**: +- Test 1: `p2_t1_s1` through `p2_t1_s5` +- Test 2: `p2_t2_s1` through `p2_t2_s5` +- Test 3: `p2_t3_s1` through `p2_t3_s4` + +### 3. Wire test-phase3.sh + +**Status**: Not started + +**Tests to wire**: +- Test 1: Boot with Future Alarms (`phase3_test1`) +- Test 2: Boot with Past Alarms (`phase3_test2`) +- Test 3: Boot with No Schedules (`phase3_test3`) +- Test 4: Silent Boot Recovery (`phase3_test4`) + +**Step IDs from plan.json**: +- Test 1: `p3_t1_s1` through `p3_t1_s5` +- Test 2: `p3_t2_s1` through `p3_t2_s5` +- Test 3: `p3_t3_s1` through `p3_t3_s4` +- Test 4: `p3_t4_s1` through `p3_t4_s4` + +### 4. Testing & Validation + +**Steps**: +1. Build Android app: `cd test-apps/android-test-app && ./gradlew assembleDebug` +2. Install on emulator/device +3. Navigate to console: Open app → should redirect to `/console/` +4. Verify console loads: Check browser console for errors +5. Test Phase A (UI-only): + - Select a test + - Manually mark steps as done/fail + - Verify state persists +6. Test Phase B (Live events): + - Set `export DNP_UI_EVENTS=1` + - Run `./test-phase1.sh 0` (just test 0) + - Verify events appear in console + - Verify step status updates automatically + +## 🎯 Quick Start: Wiring a Test Function + +Here's a complete example for wiring a test: + +```bash +test_example() { + section "TEST: Example Test" + + # Set test context (no step yet) + set_test_context "phase1" "phase1_example" "" + + # Step 1: Setup + set_test_context "phase1" "phase1_example" "p1_ex_s1" + step_start "p1_ex_s1" "Setting up test" + capture_alarms "example_initial" + step_pass "p1_ex_s1" "Setup complete" + + # Step 2: Execute + set_test_context "phase1" "phase1_example" "p1_ex_s2" + step_start "p1_ex_s2" "Executing test" + # ... do work ... + if [ success ]; then + step_pass "p1_ex_s2" "Execution complete" + else + step_fail "p1_ex_s2" "Execution failed" + fi + + # Step 3: Verify + set_test_context "phase1" "phase1_example" "p1_ex_s3" + step_start "p1_ex_s3" "Verifying results" + # ... verify ... + step_pass "p1_ex_s3" "Verification passed" + + # Verdict (automatically emits event) + set_test_context "phase1" "phase1_example" "p1_ex_s4" + verdict_pass "example_test" "Test passed" +} +``` + +## 📝 Notes + +- **Step IDs must match plan.json** - Check `console/plan.json` for exact step IDs +- **Context must be set before step events** - Call `set_test_context()` before `step_start()` +- **Verdict functions auto-emit events** - No need to manually emit verdict events +- **Evidence capture auto-emits events** - `capture_alarms()`, `capture_logcat()`, `capture_screenshot()` emit events automatically +- **Operator prompts auto-emit events** - `ui_prompt()` emits `operator_required` events automatically + +## 🔍 Verification Checklist + +After wiring tests, verify: + +- [ ] All test functions have `set_test_context()` at start +- [ ] All major steps have `step_start()` and `step_pass/fail/warn()` +- [ ] Step IDs match `plan.json` exactly +- [ ] Verdict functions are called (they emit events automatically) +- [ ] Evidence capture functions are called (they emit events automatically) +- [ ] Scripts run without errors +- [ ] Console receives events when `DNP_UI_EVENTS=1` is set + +## 🚀 Priority Order + +1. **Complete test-phase1.sh** (highest priority - most used) +2. **Wire test-phase2.sh** (medium priority) +3. **Wire test-phase3.sh** (medium priority) +4. **Test integration** (validate everything works) + +## 📚 Reference + +- **Usage Guide**: `docs/CONSOLE-USAGE.md` +- **Test Plan**: `app/src/main/assets/public/console/plan.json` +- **Event Functions**: `alarm-test-lib.sh` (functions: `emit_event`, `set_test_context`, `step_*`) diff --git a/test-apps/android-test-app/docs/CONSOLE-USAGE.md b/test-apps/android-test-app/docs/CONSOLE-USAGE.md new file mode 100644 index 0000000..e00c2b5 --- /dev/null +++ b/test-apps/android-test-app/docs/CONSOLE-USAGE.md @@ -0,0 +1,165 @@ +# Test Console Usage Guide + +## Overview + +The Test Console is a Textual-inspired operator interface that provides a structured view of test execution, real-time progress tracking, and evidence management. + +## Features + +### Phase A (UI-Only Mode) +- **Test Plan Rendering**: Visual tree of phases, tests, and steps +- **Step Checklists**: Track progress with ✓/✗/⚠/→/· status indicators +- **Current Step Frame**: Detailed instructions for the current step +- **Evidence Panel**: View captured artifacts (alarms, logs, screenshots) +- **Live Feed**: Real-time event stream +- **State Persistence**: Progress saved to localStorage + +### Phase B (Live Updates) +- **ADB Broadcast Integration**: Scripts send events via `adb shell am broadcast` +- **Real-Time Updates**: Console updates automatically as tests run +- **Automatic Status**: Step/test status updates from script events + +## Enabling Live Updates + +To enable live event streaming from test scripts: + +```bash +export DNP_UI_EVENTS=1 +./test-phase1.sh +``` + +## Test Context Setup + +In test scripts, set context before executing steps: + +```bash +# Set phase/test/step context +set_test_context "phase1" "phase1_test0" "p1_t0_s1" + +# Emit step events +step_start "p1_t0_s1" "Starting step" +# ... do work ... +step_pass "p1_t0_s1" "Step completed" +``` + +## Event Types + +### Step Events +- `step_start` - Step execution begins +- `step_pass` - Step completed successfully +- `step_warn` - Step completed with warnings +- `step_fail` - Step failed + +### Operator Events +- `operator_required` - Manual action needed (from `ui_prompt()`) + +### Evidence Events +- `artifact` - Evidence captured (from `capture_*()` functions) + +### Verdict Events +- `test_verdict` - Test completed (from `verdict_*()` functions) + +## Step ID Mapping + +Step IDs follow the pattern: `p{phase}_{test}_s{step}` + +Examples: +- `p1_t0_s1` = Phase 1, Test 0, Step 1 +- `p2_t1_s3` = Phase 2, Test 1, Step 3 +- `p3_t4_s2` = Phase 3, Test 4, Step 2 + +## Console Navigation + +1. **Select Test**: Click a test in the left sidebar +2. **View Steps**: Step checklist shows in the middle panel +3. **Follow Instructions**: Current step frame shows what to do +4. **Mark Progress**: Use "Mark Done ✓" or "Mark Fail ✗" buttons +5. **View Evidence**: Evidence panel shows captured artifacts + +## Manual Step Completion + +If scripts aren't driving the console (Phase A only), operators can manually mark steps: + +1. Select the step in the checklist +2. Click "Mark Done ✓" or "Mark Fail ✗" +3. Add notes if needed +4. Progress is saved automatically + +## Evidence Management + +Evidence is automatically captured when scripts call: +- `capture_alarms()` → `alarms/` directory +- `capture_logcat()` → `logs/` directory +- `capture_screenshot()` → `screens/` directory + +Evidence appears in the console's evidence panel when: +- Scripts emit `artifact` events (Phase B) +- Operators manually add evidence (Phase A) + +## Run ID + +Each console session generates a unique Run ID: +- Format: `YYYYMMDD_HHMMSS_xxxx` +- Used for localStorage keys and evidence organization +- Displayed in console header + +## Troubleshooting + +### Events Not Appearing + +1. **Check Event Emission**: Ensure `DNP_UI_EVENTS=1` is set +2. **Check ADB Connection**: Verify device is connected +3. **Check Plugin**: Ensure `TestEventsPlugin` is registered in Capacitor +4. **Check Broadcast**: Verify broadcast action matches in plugin + +### Console Not Loading + +1. **Check Plan JSON**: Verify `plan.json` exists and is valid +2. **Check Browser Console**: Look for JavaScript errors +3. **Check Capacitor**: Ensure Capacitor is initialized + +### Steps Not Updating + +1. **Check Context**: Verify `set_test_context()` is called +2. **Check Step IDs**: Ensure step IDs match `plan.json` +3. **Check Events**: Verify events are being emitted + +## Example: Wiring a Test Function + +```bash +test_example() { + section "TEST: Example Test" + + # Set test context + set_test_context "phase1" "phase1_example" "" + + # Step 1: Setup + set_test_context "phase1" "phase1_example" "p1_ex_s1" + step_start "p1_ex_s1" "Setting up test" + capture_alarms "example_initial" + step_pass "p1_ex_s1" "Setup complete" + + # Step 2: Execute + set_test_context "phase1" "phase1_example" "p1_ex_s2" + step_start "p1_ex_s2" "Executing test" + # ... do work ... + step_pass "p1_ex_s2" "Execution complete" + + # Step 3: Verify + set_test_context "phase1" "phase1_example" "p1_ex_s3" + step_start "p1_ex_s3" "Verifying results" + # ... verify ... + step_pass "p1_ex_s3" "Verification passed" + + # Verdict + set_test_context "phase1" "phase1_example" "p1_ex_s4" + verdict_pass "example_test" "Test passed" +} +``` + +## Next Steps + +1. **Wire Remaining Tests**: Add `set_test_context()` and `step_*()` calls to all test functions +2. **Test Console**: Build app, enable events, run a test script +3. **Refine UI**: Adjust styling, add features as needed + diff --git a/test-apps/android-test-app/docs/TEST-IMPLEMENTATION-ALIGNMENT.md b/test-apps/android-test-app/docs/TEST-IMPLEMENTATION-ALIGNMENT.md new file mode 100644 index 0000000..b9463b5 --- /dev/null +++ b/test-apps/android-test-app/docs/TEST-IMPLEMENTATION-ALIGNMENT.md @@ -0,0 +1,215 @@ +# Test Implementation Alignment with Documentation + +**Last Updated:** 2025-12-29 +**Purpose:** Document how test scripts align with golden run specifications and runbook guidance + +--- + +## Overview + +The test implementation is guided by three types of documentation: + +1. **Golden Run Documents** (`PHASE1_TEST0_GOLDEN.md`, `PHASE1_TEST1_GOLDEN.md`) + - Define **what a successful test looks like** + - Specify expected outputs, UI states, logcat patterns + - Provide pass/fail checklists + - **These are the test specifications** + +2. **Runbook** (`RUNBOOK-TESTING.md`) + - Provides **operator guidance** + - Documents how to run tests, interpret results + - Troubleshooting and common issues + +3. **Console Documentation** (`CONSOLE-USAGE.md`, `CONSOLE-REMAINING-WORK.md`) + - Documents the operator console UI + - Event-driven test execution + +--- + +## How Golden Runs Guide Implementation + +### PHASE1_TEST0_GOLDEN.md Requirements + +**Step 4 (lines 54-59) specifies prerequisites:** +``` +4. Confirmed plugin status in the UI: + - ⚙️ Plugin Settings: ✅ Configured + - 🔌 Native Fetcher: ✅ Configured + - 🔔 Notifications: ✅ Granted + - ⏰ Exact Alarms: ✅ Granted + - 📢 Channel: ✅ Enabled (High) +``` + +**Current Implementation:** +- ✅ Checks notification permissions (`check_permissions()`) +- ✅ Checks plugin configuration (`check_plugin_configured()`) +- ✅ Verifies exact alarms permission (via `dumpsys package`) +- ✅ Verifies channel status (via logcat) +- ✅ Comprehensive `verify_all_prerequisites()` function added +- ✅ Final UI verification prompt for all 5 items (aligned with golden run step 4) + +**Alignment Status:** +✅ **FULLY ALIGNED** - Test 0 now verifies all 5 prerequisites as specified in the golden run: +1. Plugin Settings: Configured (via `check_plugin_configured()`) +2. Native Fetcher: Configured (via logs + plugin config check) +3. Notifications: Granted (via `check_permissions()`) +4. Exact Alarms: Granted (via `dumpsys package`) +5. Channel: Enabled (High) (via logcat + UI verification) + +The implementation includes both programmatic checks and a final UI verification prompt that matches the golden run's step 4. + +### Pass/Fail Checklist Alignment + +**Golden Run Checklist (lines 172-194):** + +1. **Script Output:** + - ✅ "Found 1 notification alarm (expected: 1)" - **Implemented** + - ✅ "Notification alarms after rollover: 1" - **Implemented** + - ✅ "TEST 0 PASSED" verdict - **Implemented** + +2. **UI State:** + - ⚠️ "Before scheduling: Active Schedules: No" - **Not explicitly checked** + - ⚠️ "After scheduling: Active Schedules: Yes" - **Not explicitly checked** + - ⚠️ "After rollover: Active Schedules: Yes" - **Not explicitly checked** + +3. **dumpsys alarm:** + - ✅ Exactly one RTC_WAKEUP alarm - **Implemented** + - ✅ origWhen timestamp 24h later - **Not explicitly verified** + +4. **logcat:** + - ⚠️ `source=TEST_NOTIFICATION` sequence - **Not explicitly checked** + - ⚠️ `source=ROLLOVER_ON_FIRE` sequence - **Not explicitly checked** + - ✅ No duplicate DNP-SCHEDULE entries - **Partially checked** + +**Current Implementation Status:** +- Core functionality: ✅ Aligned +- Detailed verification: ⚠️ Partial alignment +- UI state checks: ❌ Not implemented +- Logcat pattern verification: ⚠️ Partial + +--- + +## How Runbook Guides Implementation + +### RUNBOOK-TESTING.md Structure + +**Section 3: Phase 1: Daily Rollover & Recovery** + +**Test Descriptions (lines 144-186):** +- Defines what each test should do +- Provides time estimates +- Lists key steps + +**Current Implementation:** +- ✅ Test purposes match runbook descriptions +- ✅ Test steps align with runbook key steps +- ✅ Time estimates are documented + +**Evidence Location (lines 187-194):** +- Specifies where evidence should be saved +- Current implementation: ✅ Aligned (`runs//`) + +--- + +## Alignment Recommendations + +### High Priority + +1. ✅ **Add Prerequisite Verification to Test 0** - **COMPLETED** + - ✅ Added `verify_all_prerequisites()` function + - ✅ Verifies all 5 UI status items (Plugin Settings, Native Fetcher, Notifications, Exact Alarms, Channel) + - ✅ Includes programmatic checks and final UI verification prompt + - ✅ Aligned with golden run step 4 + +2. **Add UI State Checks** + - Verify "Active Schedules" state before/after scheduling + - Verify "Next Notification" time updates correctly + - Can be done via UI inspection or plugin API + +3. **Add Logcat Pattern Verification** + - Check for `source=TEST_NOTIFICATION` sequence + - Check for `source=ROLLOVER_ON_FIRE` sequence + - Verify timing relationships match golden run + +### Medium Priority + +4. **Add Alarm Timestamp Verification** + - Verify `origWhen` is exactly 24h after initial time + - Can extract from `dumpsys alarm` output + +5. **Document Manual vs Automated Checks** + - Clearly distinguish what script verifies vs what operator verifies + - Align with golden run's manual verification steps + +### Low Priority + +6. **Add Screenshot Verification** + - Golden run references screenshots + - Could add automated screenshot comparison (future) + +--- + +## Current Implementation vs Golden Run + +### Test 0: Daily Rollover Verification + +| Requirement | Golden Run | Current Implementation | Status | +|------------|------------|------------------------|--------| +| Prerequisites (5 items) | ✅ All verified | ✅ All verified | ✅ Aligned | +| Schedule notification | ✅ Manual | ✅ Manual | ✅ Aligned | +| Wait for fire/advance time | ✅ Manual | ✅ Manual | ✅ Aligned | +| Verify alarm count | ✅ 1 alarm | ✅ 1 alarm | ✅ Aligned | +| Verify rollover | ✅ Tomorrow scheduled | ✅ Tomorrow scheduled | ✅ Aligned | +| UI state checks | ✅ Before/after | ❌ Not checked | Gap | +| Logcat patterns | ✅ Sequences verified | ⚠️ Partial | Partial | +| Alarm timestamp | ✅ 24h verified | ❌ Not verified | Gap | + +### Test 1: Force-Stop Recovery + +| Requirement | Golden Run | Current Implementation | Status | +|------------|------------|------------------------|--------| +| Clean start | ✅ Auto-reset | ✅ Auto-reset | ✅ Aligned | +| Schedule notification | ✅ Manual | ✅ Manual | ✅ Aligned | +| Force-stop app | ✅ Manual | ✅ Manual | ✅ Aligned | +| Verify alarms cleared | ✅ 0 alarms | ✅ 0 alarms | ✅ Aligned | +| Relaunch app | ✅ Manual | ✅ Manual | ✅ Aligned | +| Verify recovery | ✅ 1 alarm restored | ✅ 1 alarm restored | ✅ Aligned | +| Recovery logs | ✅ FORCE_STOP scenario | ✅ FORCE_STOP scenario | ✅ Aligned | + +--- + +## Next Steps + +1. **Enhance Test 0 Prerequisites** + - Add explicit checks for exact alarms and channel status + - Use plugin API to verify all 5 items programmatically + +2. **Add UI State Verification** + - Check "Active Schedules" state via plugin API or UI inspection + - Verify "Next Notification" time updates + +3. **Add Logcat Pattern Checks** + - Verify `source=TEST_NOTIFICATION` and `source=ROLLOVER_ON_FIRE` sequences + - Check timing relationships + +4. **Update Golden Run Documents** + - Document which checks are automated vs manual + - Clarify operator responsibilities + +--- + +## Conclusion + +**Current State:** +- Core test functionality: ✅ Well aligned with golden runs +- Detailed verification: ⚠️ Partial alignment +- Prerequisites: ⚠️ Need to verify all 5 items + +**Key Insight:** +The golden run documents specify **what** should be verified, but don't always specify **how** (automated vs manual). Our implementation should: +1. Automate what can be automated +2. Clearly document what requires manual verification +3. Align with the golden run's verification sequence + +**Priority:** +Focus on adding the missing prerequisite checks (exact alarms, channel status) to fully align with the golden run specification. diff --git a/test-apps/android-test-app/test-phase2.sh b/test-apps/android-test-app/test-phase2.sh index db7c701..ad9d35b 100755 --- a/test-apps/android-test-app/test-phase2.sh +++ b/test-apps/android-test-app/test-phase2.sh @@ -39,6 +39,9 @@ SELECTED_TESTS=() test1_force_stop_cleared_alarms() { section "TEST 1: Force Stop – Alarms Cleared" + # Set test context + set_test_context "phase2" "phase2_test1" "" + info "Purpose: Verify force stop detection and alarm rescheduling when alarms are cleared." info "Expected time: 5-8 minutes" info "Automatable: Partial (requires manual force-stop verification)" @@ -47,6 +50,8 @@ test1_force_stop_cleared_alarms() { pause # Capture initial state + set_test_context "phase2" "phase2_test1" "p2_t1_s1" + step_start "p2_t1_s1" "Launch app & check plugin status" capture_alarms "phase2_test1_initial" capture_logcat "phase2_test1_initial" "DNP" 50 @@ -62,10 +67,14 @@ test1_force_stop_cleared_alarms() { ui_prompt "2) Now schedule at least one future notification (e.g., click 'Test Notification' to schedule for a few minutes in the future)." + step_pass "p2_t1_s1" "Plugin configured and notification scheduled" + # Capture before force-stop state capture_alarms "phase2_test1_before_force_stop" substep "Step 2: Verify alarms are scheduled" + set_test_context "phase2" "phase2_test1" "p2_t1_s2" + step_start "p2_t1_s2" "Verify alarms scheduled" show_alarms local before_count system_count before_count="$(get_plugin_alarm_count)" @@ -75,21 +84,29 @@ test1_force_stop_cleared_alarms() { if [[ "$before_count" -eq 0 ]]; then warn "No plugin alarms found before force stop; TEST 1 may not be meaningful." + step_warn "p2_t1_s2" "No alarms found" elif [[ "$before_count" -eq 1 ]]; then ok "Single plugin alarm confirmed (one per day)" + step_pass "p2_t1_s2" "Alarms verified" else warn "Found $before_count plugin alarms (expected: 1)" + step_warn "p2_t1_s2" "Unexpected alarm count" fi pause substep "Step 3: Force stop app (should clear alarms on many devices)" + set_test_context "phase2" "phase2_test1" "p2_t1_s3" + step_start "p2_t1_s3" "Force stop app" force_stop_app # Capture after force-stop state capture_alarms "phase2_test1_after_force_stop" + step_pass "p2_t1_s3" "App force stopped" substep "Step 4: Check alarms after force stop" + set_test_context "phase2" "phase2_test1" "p2_t1_s4" + step_start "p2_t1_s4" "Check alarms after force stop" local after_count system_after after_count="$(get_plugin_alarm_count)" system_after="$(get_system_alarm_count)" @@ -100,10 +117,14 @@ test1_force_stop_cleared_alarms() { if [[ "$after_count" -gt 0 ]]; then if [[ "$STRICTNESS" == "hard" ]]; then error "Plugin alarms still present after force stop (strict mode: hard)" + step_fail "p2_t1_s4" "Alarms not cleared" else warn "Plugin alarms still present after force stop. This device/OS may not clear alarms on force stop." warn "TEST 1 will continue but may not fully validate FORCE_STOP scenario." + step_warn "p2_t1_s4" "Alarms may not be cleared (device-specific)" fi + else + step_pass "p2_t1_s4" "Alarms cleared" fi pause @@ -115,6 +136,8 @@ test1_force_stop_cleared_alarms() { info "Boot flag cleared (if it existed)" substep "Step 5: Launch app (triggers recovery) and capture logs" + set_test_context "phase2" "phase2_test1" "p2_t1_s5" + step_start "p2_t1_s5" "Relaunch app & verify recovery" clear_logs launch_app sleep 5 # give recovery a moment to run @@ -191,6 +214,13 @@ test1_force_stop_cleared_alarms() { fi # Emit verdict + if [[ "$test1_passed" == "true" ]]; then + step_pass "p2_t1_s5" "Recovery successful" + else + step_fail "p2_t1_s5" "Recovery failed or inconclusive" + fi + + set_test_context "phase2" "phase2_test1" "p2_t1_s6" if [[ "$test1_passed" == "true" ]]; then verdict_pass "phase2_test1_force_stop_cleared" "$test1_message" elif [[ "$STRICTNESS" == "hard" ]]; then @@ -212,6 +242,9 @@ test1_force_stop_cleared_alarms() { test2_force_stop_intact_alarms() { section "TEST 2: Force Stop / Process Stop – Alarms Intact" + # Set test context + set_test_context "phase2" "phase2_test2" "" + info "Purpose: Verify that heavy FORCE_STOP recovery does not run when alarms are still present." info "Expected time: 4-6 minutes" info "Automatable: Partial (requires manual verification)" @@ -220,6 +253,8 @@ test2_force_stop_intact_alarms() { pause # Capture initial state + set_test_context "phase2" "phase2_test2" "p2_t2_s1" + step_start "p2_t2_s1" "Schedule notification" capture_alarms "phase2_test2_initial" capture_logcat "phase2_test2_initial" "DNP" 50 @@ -229,10 +264,14 @@ test2_force_stop_intact_alarms() { Press Enter when done." + step_pass "p2_t2_s1" "Notification scheduled" + # Capture before soft stop state capture_alarms "phase2_test2_before_soft_stop" substep "Step 2: Verify alarms are scheduled" + set_test_context "phase2" "phase2_test2" "p2_t2_s2" + step_start "p2_t2_s2" "Force stop app" show_alarms local before system_before before="$(get_plugin_alarm_count)" @@ -242,24 +281,32 @@ test2_force_stop_intact_alarms() { if [[ "$before" -eq 0 ]]; then warn "No plugin alarms found; TEST 2 may not be meaningful." + step_warn "p2_t2_s2" "No alarms found" elif [[ "$before" -eq 1 ]]; then ok "Single plugin alarm confirmed (one per day)" + step_pass "p2_t2_s2" "Alarms verified" else warn "Found $before plugin alarms (expected: 1)" + step_warn "p2_t2_s2" "Unexpected alarm count" fi pause substep "Step 3: Simulate a 'soft' stop or process kill that does NOT clear alarms" + set_test_context "phase2" "phase2_test2" "p2_t2_s2" + step_start "p2_t2_s2" "Force stop app" info "Killing app process (non-destructive - may not clear alarms)..." $ADB_BIN shell am kill "$APP_ID" || true sleep 2 ok "Kill signal sent (soft stop)" + step_pass "p2_t2_s2" "App force stopped" # Capture after soft stop state capture_alarms "phase2_test2_after_soft_stop" substep "Step 4: Verify alarms are still scheduled" + set_test_context "phase2" "phase2_test2" "p2_t2_s3" + step_start "p2_t2_s3" "Verify alarms intact" local after system_after after="$(get_plugin_alarm_count)" system_after="$(get_system_alarm_count)" @@ -270,14 +317,20 @@ test2_force_stop_intact_alarms() { if [[ "$after" -eq 0 ]]; then if [[ "$STRICTNESS" == "hard" ]]; then error "Alarms cleared after soft stop (strict mode: hard)" + step_fail "p2_t2_s3" "Alarms cleared" else warn "Alarms appear cleared after soft stop; this environment may not distinguish TEST 2 well." + step_warn "p2_t2_s3" "Alarms may be cleared" fi + else + step_pass "p2_t2_s3" "Alarms intact" fi pause substep "Step 5: Relaunch app and check recovery logs" + set_test_context "phase2" "phase2_test2" "p2_t2_s4" + step_start "p2_t2_s4" "Relaunch & verify behavior" clear_logs launch_app sleep 5 @@ -341,7 +394,14 @@ test2_force_stop_intact_alarms() { fi fi + if [[ "$test2_passed" == "true" ]]; then + step_pass "p2_t2_s4" "Recovery behavior correct" + else + step_fail "p2_t2_s4" "Recovery behavior incorrect" + fi + # Emit verdict + set_test_context "phase2" "phase2_test2" "p2_t2_s5" if [[ "$test2_passed" == "true" ]]; then verdict_pass "phase2_test2_force_stop_intact" "$test2_message" elif [[ "$STRICTNESS" == "hard" ]]; then @@ -360,6 +420,9 @@ test2_force_stop_intact_alarms() { test3_first_launch_no_schedules() { section "TEST 3: First Launch / No Schedules Safeguard" + # Set test context + set_test_context "phase2" "phase2_test3" "" + info "Purpose: Ensure force-stop recovery is NOT triggered when DB is empty or plugin isn't configured." info "Expected time: 3-5 minutes" info "Automatable: Yes" @@ -368,6 +431,8 @@ test3_first_launch_no_schedules() { pause # Capture initial state (before uninstall) + set_test_context "phase2" "phase2_test3" "p2_t3_s1" + step_start "p2_t3_s1" "Fresh install" capture_alarms "phase2_test3_initial" substep "Step 1: Uninstall app to clear DB/state" @@ -379,8 +444,10 @@ test3_first_launch_no_schedules() { substep "Step 2: Reinstall app" if $ADB_BIN install -r "$APK_PATH"; then ok "App installed" + step_pass "p2_t3_s1" "Fresh install complete" else error "Reinstall failed" + step_fail "p2_t3_s1" "Reinstall failed" exit 1 fi @@ -391,6 +458,8 @@ test3_first_launch_no_schedules() { pause substep "Step 3: Launch app for the first time" + set_test_context "phase2" "phase2_test3" "p2_t3_s2" + step_start "p2_t3_s2" "Launch app & verify no recovery" launch_app sleep 5 @@ -400,6 +469,8 @@ test3_first_launch_no_schedules() { capture_screenshot "phase2_test3_after_first_launch" substep "Step 4: Collect logs and ensure no force-stop recovery ran" + set_test_context "phase2" "phase2_test3" "p2_t3_s3" + step_start "p2_t3_s3" "Verify no recovery ran" local logs logs="$(get_recovery_logs)" echo "$logs" @@ -443,7 +514,14 @@ test3_first_launch_no_schedules() { fi fi + if [[ "$test3_passed" == "true" ]]; then + step_pass "p2_t3_s3" "No recovery ran (correct)" + else + step_fail "p2_t3_s3" "Recovery ran when it shouldn't" + fi + # Emit verdict + set_test_context "phase2" "phase2_test3" "p2_t3_s4" if [[ "$test3_passed" == "true" ]]; then verdict_pass "phase2_test3_first_launch_no_schedules" "$test3_message" elif [[ "$STRICTNESS" == "hard" ]]; then diff --git a/test-apps/android-test-app/test-phase3.sh b/test-apps/android-test-app/test-phase3.sh index 281cab4..2c239d2 100755 --- a/test-apps/android-test-app/test-phase3.sh +++ b/test-apps/android-test-app/test-phase3.sh @@ -51,6 +51,9 @@ extract_scenario_from_logs() { test1_boot_future_alarms() { section "TEST 1: Boot with Future Alarms" + # Set test context + set_test_context "phase3" "phase3_test1" "" + info "Purpose: Verify alarms are recreated on boot when schedules have future run times." info "Expected time: 2-3 minutes (includes 30-60s reboot)" info "Automatable: Partial (requires manual reboot confirmation)" @@ -60,6 +63,8 @@ test1_boot_future_alarms() { pause # Capture initial state + set_test_context "phase3" "phase3_test1" "p3_t1_s1" + step_start "p3_t1_s1" "Launch app & check plugin status" capture_alarms "phase3_test1_initial" capture_logcat "phase3_test1_initial" "DNP" 50 @@ -75,10 +80,14 @@ test1_boot_future_alarms() { ui_prompt "2) Now schedule at least one future notification (e.g., click 'Test Notification' to schedule for a few minutes in the future)." + step_pass "p3_t1_s1" "Plugin configured and notification scheduled" + # Capture before reboot state capture_alarms "phase3_test1_before_reboot" substep "Step 2: Verify alarms are scheduled" + set_test_context "phase3" "phase3_test1" "p3_t1_s2" + step_start "p3_t1_s2" "Verify alarms scheduled" show_alarms local before_count system_before before_count="$(get_plugin_alarm_count)" @@ -88,20 +97,28 @@ test1_boot_future_alarms() { if [[ "$before_count" -eq 0 ]]; then warn "No plugin alarms found before reboot; TEST 1 may not be meaningful." + step_warn "p3_t1_s2" "No alarms found" elif [[ "$before_count" -eq 1 ]]; then ok "Single plugin alarm confirmed (one per day)" + step_pass "p3_t1_s2" "Alarms verified" else warn "Found $before_count plugin alarms (expected: 1)" + step_warn "p3_t1_s2" "Unexpected alarm count" fi pause substep "Step 3: Reboot emulator" + set_test_context "phase3" "phase3_test1" "p3_t1_s3" + step_start "p3_t1_s3" "Reboot emulator" warn "The emulator will reboot now. This will take 30-60 seconds." pause reboot_emulator + step_pass "p3_t1_s3" "Emulator rebooted" substep "Step 4: Collect boot recovery logs" + set_test_context "phase3" "phase3_test1" "p3_t1_s4" + step_start "p3_t1_s4" "Collect boot recovery logs" info "Collecting recovery logs from boot..." sleep 2 # Give recovery a moment to complete @@ -167,11 +184,16 @@ test1_boot_future_alarms() { warn "Alarms were not recreated despite recovery success. Check alarm scheduling logic." test1_message="Boot recovery succeeded but alarms not recreated (rescheduled=$rescheduled, after_count=$after_count)" test1_passed=false + step_fail "p3_t1_s4" "Alarms not recreated" elif [[ "$after_count" -gt 0 && "$test1_passed" == "true" ]]; then ok "Alarms successfully recreated after boot (after_count=$after_count)" + step_pass "p3_t1_s4" "Boot recovery successful" + else + step_fail "p3_t1_s4" "Boot recovery failed" fi # Emit verdict + set_test_context "phase3" "phase3_test1" "p3_t1_s5" if [[ "$test1_passed" == "true" ]]; then verdict_pass "phase3_test1_boot_future_alarms" "$test1_message" else @@ -188,6 +210,9 @@ test1_boot_future_alarms() { test2_boot_past_alarms() { section "TEST 2: Boot with Past Alarms" + # Set test context + set_test_context "phase3" "phase3_test2" "" + info "Purpose: Verify missed alarms are detected and next occurrence is scheduled on boot." info "Expected time: 5-6 minutes (includes 3min wait + 30-60s reboot)" info "Automatable: Partial (requires manual time advancement or wait)" @@ -198,6 +223,8 @@ test2_boot_past_alarms() { pause # Capture initial state + set_test_context "phase3" "phase3_test2" "p3_t2_s1" + step_start "p3_t2_s1" "Schedule notification for past time" capture_alarms "phase3_test2_initial" capture_logcat "phase3_test2_initial" "DNP" 50 @@ -215,18 +242,25 @@ test2_boot_past_alarms() { After scheduling, we'll wait for the alarm time to pass, then reboot." + step_pass "p3_t2_s1" "Notification scheduled" + # Capture before wait state capture_alarms "phase3_test2_before_wait" substep "Step 2: Wait for alarm time to pass" + set_test_context "phase3" "phase3_test2" "p3_t2_s2" + step_start "p3_t2_s2" "Wait for alarm time to pass" info "Waiting 3 minutes for scheduled alarm time to pass..." warn "You can manually advance system time if needed (requires root/emulator)" sleep 180 # Wait 3 minutes + step_pass "p3_t2_s2" "Alarm time passed" # Capture after wait state capture_alarms "phase3_test2_after_wait" substep "Step 3: Verify alarm time has passed" + set_test_context "phase3" "phase3_test2" "p3_t2_s3" + step_start "p3_t2_s3" "Reboot emulator" info "Alarm time should now be in the past" show_alarms @@ -236,8 +270,11 @@ test2_boot_past_alarms() { warn "The emulator will reboot now. This will take 30-60 seconds." pause reboot_emulator + step_pass "p3_t2_s3" "Emulator rebooted" substep "Step 5: Collect boot recovery logs" + set_test_context "phase3" "phase3_test2" "p3_t2_s4" + step_start "p3_t2_s4" "Collect boot recovery logs" info "Collecting recovery logs from boot..." sleep 2 @@ -288,7 +325,14 @@ test2_boot_past_alarms() { test2_message="No missed alarms detected. Verify alarm time actually passed before reboot (missed=$missed, rescheduled=$rescheduled)" fi + if [[ "$test2_passed" == "true" ]]; then + step_pass "p3_t2_s4" "Past alarms detected and rescheduled" + else + step_fail "p3_t2_s4" "Past alarms not detected" + fi + # Emit verdict + set_test_context "phase3" "phase3_test2" "p3_t2_s5" if [[ "$test2_passed" == "true" ]]; then verdict_pass "phase3_test2_boot_past_alarms" "$test2_message" else @@ -305,6 +349,9 @@ test2_boot_past_alarms() { test3_boot_no_schedules() { section "TEST 3: Boot with No Schedules" + # Set test context + set_test_context "phase3" "phase3_test3" "" + info "Purpose: Verify boot recovery handles empty database gracefully." info "Expected time: 2-3 minutes (includes 30-60s reboot)" info "Automatable: Yes" @@ -314,6 +361,8 @@ test3_boot_no_schedules() { pause # Capture initial state (before uninstall) + set_test_context "phase3" "phase3_test3" "p3_t3_s1" + step_start "p3_t3_s1" "Fresh install" capture_alarms "phase3_test3_initial" substep "Step 1: Uninstall app to clear DB/state" @@ -325,8 +374,10 @@ test3_boot_no_schedules() { substep "Step 2: Reinstall app" if $ADB_BIN install -r "$APK_PATH"; then ok "App installed" + step_pass "p3_t3_s1" "Fresh install complete" else error "Reinstall failed" + step_fail "p3_t3_s1" "Reinstall failed" exit 1 fi @@ -337,12 +388,17 @@ test3_boot_no_schedules() { pause substep "Step 3: Reboot emulator WITHOUT scheduling anything" + set_test_context "phase3" "phase3_test3" "p3_t3_s2" + step_start "p3_t3_s2" "Reboot without schedules" warn "Do NOT schedule any notifications. The app should have no schedules in the database." warn "The emulator will reboot now. This will take 30-60 seconds." pause reboot_emulator + step_pass "p3_t3_s2" "Emulator rebooted" substep "Step 4: Collect boot recovery logs" + set_test_context "phase3" "phase3_test3" "p3_t3_s3" + step_start "p3_t3_s3" "Verify no recovery ran" info "Collecting recovery logs from boot..." sleep 2 @@ -390,7 +446,14 @@ test3_boot_no_schedules() { test3_message="Logs present but no rescheduling; review scenario handling to ensure it's explicit about NONE / NO_SCHEDULES (scenario=${scenario:-}, rescheduled=$rescheduled)" fi + if [[ "$test3_passed" == "true" ]]; then + step_pass "p3_t3_s3" "No recovery ran (correct)" + else + step_fail "p3_t3_s3" "Recovery ran when it shouldn't" + fi + # Emit verdict + set_test_context "phase3" "phase3_test3" "p3_t3_s4" if [[ "$test3_passed" == "true" ]]; then verdict_pass "phase3_test3_boot_no_schedules" "$test3_message" else @@ -407,6 +470,9 @@ test3_boot_no_schedules() { test4_silent_boot_recovery() { section "TEST 4: Silent Boot Recovery (App Never Opened)" + # Set test context + set_test_context "phase3" "phase3_test4" "" + info "Purpose: Verify boot recovery occurs even when the app is never opened after reboot." info "Expected time: 2-3 minutes (includes 30-60s reboot)" info "Automatable: Partial (requires manual verification that app was not opened)" @@ -416,6 +482,8 @@ test4_silent_boot_recovery() { pause # Capture initial state + set_test_context "phase3" "phase3_test4" "p3_t4_s1" + step_start "p3_t4_s1" "Schedule notification" capture_alarms "phase3_test4_initial" capture_logcat "phase3_test4_initial" "DNP" 50 @@ -431,10 +499,14 @@ test4_silent_boot_recovery() { ui_prompt "2) Click 'Test Notification' to schedule a notification for a few minutes in the future." + step_pass "p3_t4_s1" "Notification scheduled" + # Capture before reboot state capture_alarms "phase3_test4_before_reboot" substep "Step 2: Verify alarms are scheduled" + set_test_context "phase3" "phase3_test4" "p3_t4_s2" + step_start "p3_t4_s2" "Reboot without opening app" show_alarms local before_count system_before before_count="$(get_plugin_alarm_count)" @@ -444,10 +516,13 @@ test4_silent_boot_recovery() { if [[ "$before_count" -eq 0 ]]; then warn "No plugin alarms found; TEST 4 may not be meaningful." + step_warn "p3_t4_s2" "No alarms found" elif [[ "$before_count" -eq 1 ]]; then ok "Single plugin alarm confirmed (one per day)" + step_pass "p3_t4_s2" "Alarms verified" else warn "Found $before_count plugin alarms (expected: 1)" + step_warn "p3_t4_s2" "Unexpected alarm count" fi pause @@ -457,8 +532,11 @@ test4_silent_boot_recovery() { warn "The emulator will reboot now. This will take 30-60 seconds." pause reboot_emulator + step_pass "p3_t4_s2" "Emulator rebooted (app not opened)" substep "Step 4: Collect boot recovery logs (without opening app)" + set_test_context "phase3" "phase3_test4" "p3_t4_s3" + step_start "p3_t4_s3" "Collect boot recovery logs" info "Collecting recovery logs from boot (app was NOT opened)..." sleep 2 @@ -518,7 +596,14 @@ test4_silent_boot_recovery() { test4_message="Boot recovery not detected. Verify boot receiver is registered and has BOOT_COMPLETED permission (scenario=${scenario:-}, rescheduled=$rescheduled)" fi + if [[ "$test4_passed" == "true" ]]; then + step_pass "p3_t4_s3" "Silent boot recovery successful" + else + step_fail "p3_t4_s3" "Silent boot recovery failed" + fi + # Emit verdict + set_test_context "phase3" "phase3_test4" "p3_t4_s4" if [[ "$test4_passed" == "true" ]]; then verdict_pass "phase3_test4_silent_boot_recovery" "$test4_message" else