Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions beam/tests/mobile/test_camera_scanner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright (c) 2024, AgriTheory and contributors
# For license information, please see license.txt

# Tests for camera scanner component.


from playwright.sync_api import expect


def test_camera_scanner_button_hidden(page, setup):
page.add_init_script(
"""
Object.defineProperty(navigator, 'mediaDevices', {
value: {
enumerateDevices: async () => {
return []; // No cameras available
},
getUserMedia: async (constraints) => {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
return canvas.captureStream(30);
}
},
});
"""
)

page.get_by_text("Move").click()
page.wait_for_url("**/beam#/move")

# Wait for component to check permissions and decide to hide itself
page.wait_for_timeout(1500)

# The component should not be visible when no cameras are found
camera_scanner = page.locator(".camera-scanner")
expect(camera_scanner).to_have_count(0)


def test_camera_scanner_button_visible(page, setup):
page.add_init_script(
"""
navigator.mediaDevices.enumerateDevices = async () => {
return [
{
kind: 'videoinput',
deviceId: 'mock-camera-1',
label: 'Mock Camera',
groupId: 'mock-group'
}
];
};
"""
)

page.get_by_text("Move").click()
page.wait_for_url("**/beam#/move")

page.wait_for_timeout(1000)

camera_button = page.locator("button:has-text('Open Camera')")
expect(camera_button).to_be_visible()


def test_camera_scanner_activates_camera(page, setup):
page.add_init_script(
"""
window.getUserMediaCalled = false;
window.getUserMediaConstraints = null;

Object.defineProperty(navigator, 'mediaDevices', {
value: {
enumerateDevices: async () => {
return [
{
kind: 'videoinput',
deviceId: 'mock-camera-1',
label: 'Mock Camera',
groupId: 'mock-group'
}
];
},
getUserMedia: async (constraints) => {
window.getUserMediaCalled = true;
window.getUserMediaConstraints = constraints;
// Return a minimal fake stream
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
return canvas.captureStream(30);
}
},
writable: false,
configurable: true
});
"""
)

page.get_by_text("Move").click()
page.wait_for_url("**/beam#/move")
page.wait_for_timeout(2000)

was_called_before = page.evaluate("window.getUserMediaCalled")
assert was_called_before == False, "getUserMedia should not be called before clicking button"

camera_button = page.locator("button:has-text('Open Camera')")
expect(camera_button).to_be_enabled(timeout=10000)

camera_button.click()
page.wait_for_timeout(1000)

was_called_after = page.evaluate("window.getUserMediaCalled")
assert was_called_after == True, "getUserMedia should be called after clicking 'Open Camera'"

# Verify correct constraints (should request video with back camera)
constraints = page.evaluate("window.getUserMediaConstraints")
assert constraints is not None, "getUserMedia should receive constraints"
assert "video" in constraints, "Should request video stream"
assert constraints["video"]["facingMode"] == "environment", "Should request back camera"


def test_camera_scanner_permission_denied(page, setup):
# Mock camera APIs to simulate permission denial
page.add_init_script(
"""
Object.defineProperty(navigator, 'mediaDevices', {
value: {
enumerateDevices: async () => {
return [
{
kind: 'videoinput',
deviceId: 'mock-camera-1',
label: 'Mock Camera',
groupId: 'mock-group'
}
];
},
getUserMedia: async (constraints) => {
const error = new Error('Permission denied');
error.name = 'NotAllowedError';
throw error;
}
},
writable: false,
configurable: true
});
"""
)

page.get_by_text("Move").click()
page.wait_for_url("**/beam#/move")
page.wait_for_timeout(2000)

camera_button = page.locator("button:has-text('Open Camera')")
expect(camera_button).to_be_enabled(timeout=10000)

camera_button.click()

page.wait_for_timeout(500)

# Verify error message is displayed
error_message = page.locator(".error-message")
expect(error_message).to_be_visible()
expect(error_message).to_contain_text("Permission denied")
210 changes: 210 additions & 0 deletions beam/www/beam/components/CameraScanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<template>
<div v-if="showComponent" class="camera-scanner">
<BeamBtn @click="toggleCamera" :disabled="!canUseCamera">
<span v-if="!isScanning">Open Camera</span>
<span v-else>Close</span>
</BeamBtn>

<div v-if="isScanning" class="scanner-container">
<div id="camera-reader"></div>
<p v-if="lastScanned" class="last-scan">Last scanned: {{ lastScanned }}</p>
</div>

<div v-if="errorMessage" class="error-message">
<p>
<strong>{{ errorMessage }}</strong>
</p>
<div v-if="showPermissionHelp">
<p>You must allow camera access in your browser settings.</p>
<p><small>On mobile devices, check the permissions in Settings → Apps → Browser</small></p>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, onUnmounted, computed, onMounted } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'

const emit = defineEmits<{
scan: [barcode: string, qty: number]
}>()

const isScanning = ref(false)
const showComponent = ref(true)
const lastScanned = ref<string>('')
const errorMessage = ref<string>('')
const showPermissionHelp = ref(false)
const hasCheckedPermissions = ref(false)
let html5QrCode: Html5Qrcode | null = null

const canUseCamera = computed(() => {
return !errorMessage.value || isScanning.value
})

const checkCameraPermissions = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
errorMessage.value = 'Your browser does not support camera access'
return
}

const devices = await navigator.mediaDevices.enumerateDevices()
const cameras = devices.filter(device => device.kind === 'videoinput')

if (cameras.length === 0) {
errorMessage.value = 'There is no camera found on this device'
showComponent.value = false
return
}

hasCheckedPermissions.value = true
errorMessage.value = ''
} catch (err) {
errorMessage.value = err.message || err.toString()
}
}

const startScanning = async () => {
errorMessage.value = ''
showPermissionHelp.value = false
isScanning.value = true

try {
await new Promise(resolve => setTimeout(resolve, 100))

try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
stream.getTracks().forEach(track => track.stop())
} catch (err: any) {
isScanning.value = false

if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage.value = 'Permission denied to access camera'
showPermissionHelp.value = true
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage.value = 'There is no camera found on this device'
} else {
errorMessage.value = err.message || err.toString()
}
return
}

html5QrCode = new Html5Qrcode('camera-reader', {
formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.EAN_8,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.UPC_E,
],
verbose: false,
})

const config = {
fps: 1,
qrbox: { width: 280, height: 280 },
aspectRatio: 1.0,
}

const cameraConfig = { facingMode: 'environment' }

await html5QrCode.start(
cameraConfig,
config,
decodedText => {
lastScanned.value = decodedText
emit('scan', decodedText, 1)
},
() => {
// Error callback
}
)
} catch (err: any) {
isScanning.value = false
errorMessage.value = err.message || err.toString()
}
}

const stopScanning = async () => {
if (html5QrCode && isScanning.value) {
try {
await html5QrCode.stop()
html5QrCode.clear()
isScanning.value = false
lastScanned.value = ''
} catch (err) {
errorMessage.value = err.message || err.toString()
}
}
}

const toggleCamera = async () => {
if (!isScanning.value) {
await startScanning()
} else {
await stopScanning()
}
}

onMounted(async () => {
await checkCameraPermissions()
})

onUnmounted(() => {
if (isScanning.value) {
stopScanning()
}
})
</script>

<style scoped>
.camera-scanner {
margin: 1rem 0;
}

.scanner-container {
background: #000;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}

#camera-reader {
width: 100%;
height: 150px;
position: relative;
border: 2px solid #42b983;
border-radius: 4px;
overflow: hidden;
}

.last-scan {
color: #42b983;
text-align: center;
margin-top: 0.5rem;
font-weight: bold;
font-size: 1rem;
}

.error-message {
color: #721c24;
text-align: left;
padding: 1rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-top: 0.5rem;
}

.error-message strong {
display: block;
margin-bottom: 0.5rem;
}
</style>
Loading
Loading