Skip to content
Merged
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
70 changes: 70 additions & 0 deletions .github/workflows/release-branch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Branch build and release

on:
push:
branches:
- '**'

permissions:
contents: write

Comment on lines +8 to +10
jobs:
build-and-release:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install dependencies
run: npm install

Comment on lines +24 to +26
- name: Build package
run: npm run build

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Run integration test
run: npm run test:integration

- name: Create npm package
id: package
if: github.ref == 'refs/heads/release'
shell: bash
run: |
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="${GITHUB_SHA::7}"
TAG="v${VERSION}-${SHORT_SHA}"
PACKAGE_TGZ="$(npm pack --silent)"

echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "package_tgz=${PACKAGE_TGZ}" >> "$GITHUB_OUTPUT"

- name: Create GitHub release
if: github.ref == 'refs/heads/release'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
gh release create "${{ steps.package.outputs.tag }}" \
"${{ steps.package.outputs.package_tgz }}" \
qr-scanner-worker.min.js \
qr-scanner-worker.min.js.map \
qr-scanner.legacy.min.js \
qr-scanner.legacy.min.js.map \
qr-scanner.min.js \
qr-scanner.min.js.map \
qr-scanner.umd.min.js \
qr-scanner.umd.min.js.map \
types/qr-scanner.d.ts \
--target "${GITHUB_SHA}" \
--title "qr-scanner v${{ steps.package.outputs.version }} (${{ steps.package.outputs.short_sha }})" \
--generate-notes
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,15 @@ Building:
```batch
yarn build
```

## Integration test

An end-to-end Playwright test is available to verify webcam-based QR recognition against the fake camera stream in
`playwright/fixtures/VideoOfQrCode.mjpeg`.

Install dependencies, install the Chromium test browser, then run:

```batch
npx playwright install chromium
npm run test:integration
```
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"types/qr-scanner.d.ts"
],
"scripts": {
"build": "rollup --config && tsc src/qr-scanner.ts --target esnext --module esnext --declaration --declarationDir types --emitDeclarationOnly"
"build": "rollup --config && tsc src/qr-scanner.ts --target esnext --module esnext --declaration --declarationDir types --emitDeclarationOnly",
"test:integration": "playwright test"
},
"repository": {
"type": "git",
Expand All @@ -45,6 +46,7 @@
"@types/offscreencanvas": "^2019.6.4"
},
"devDependencies": {
"@playwright/test": "^1.54.1",
"@ampproject/rollup-plugin-closure-compiler": "^0.27.0",
"@rollup/plugin-alias": "^3.1.9",
"@rollup/plugin-typescript": "^8.3.0",
Expand Down
44 changes: 44 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const path = require('node:path');
const { devices } = require('@playwright/test');

const pathToCamFakeStream = path.resolve(
__dirname,
'playwright',
'fixtures',
'VideoOfQrCode.mjpeg',
);

/** @type {import('@playwright/test').PlaywrightTestConfig} */
module.exports = {
testDir: './playwright',
testMatch: '**/*.spec.js',
timeout: 60 * 1000,
retries: 0,
workers: 1,
reporter: 'list',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:4173',
headless: true,
trace: 'retain-on-failure',
permissions: ['camera'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
`--use-file-for-fake-video-capture=${pathToCamFakeStream}`,
],
},
},
projects: [
{
name: 'chromium',
},
],
webServer: {
command: 'node playwright/support/static-server.cjs',
url: 'http://127.0.0.1:4173/playwright/fixtures/qr-camera-harness.html',
reuseExistingServer: false,
timeout: 30 * 1000,
},
};
Binary file added playwright/fixtures/VideoOfQrCode.mjpeg
Binary file not shown.
86 changes: 86 additions & 0 deletions playwright/fixtures/qr-camera-harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>QR scanner integration harness</title>
</head>
<body>
<p id="status">booting</p>
<pre id="result"></pre>
<pre id="error"></pre>
<video id="video" muted playsinline autoplay></video>

<script type="module">
import QrScanner from '/qr-scanner.min.js';

QrScanner._disableBarcodeDetector = true;

const statusElement = document.getElementById('status');
const resultElement = document.getElementById('result');
const errorElement = document.getElementById('error');
const videoElement = document.getElementById('video');

const setStatus = (status) => {
statusElement.textContent = status;
window.__scanState = {
...window.__scanState,
status,
};
};

window.__scanState = {
status: 'booting',
result: null,
error: null,
cornerPoints: [],
};

let scanner;

const handleDecode = (result) => {
resultElement.textContent = result.data;
window.__scanState = {
status: 'decoded',
result: result.data,
error: null,
cornerPoints: result.cornerPoints,
};
scanner.stop();
};

const handleError = (error) => {
const message = error instanceof Error ? error.message : String(error);
if (message === QrScanner.NO_QR_CODE_FOUND) return;

errorElement.textContent = message;
window.__scanState = {
...window.__scanState,
error: message,
};
setStatus('error');
};

try {
scanner = new QrScanner(videoElement, handleDecode, {
onDecodeError: handleError,
preferredCamera: 'user',
maxScansPerSecond: 10,
returnDetailedScanResult: true,
});
setStatus('starting');
await scanner.start();
setStatus('scanning');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errorElement.textContent = message;
window.__scanState = {
...window.__scanState,
error: message,
};
setStatus('error');
}

window.addEventListener('beforeunload', () => scanner && scanner.destroy());
</script>
</body>
</html>
23 changes: 23 additions & 0 deletions playwright/qr-recognition.integration.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const { test, expect } = require('@playwright/test');

const expectedQrPayload =
'_R1-AT1_4_ft3F472#245326_2021-08-09T13:54:09_0,00_0,00_0,00_0,00_11,10_+tJArYw=_4450931f_3ijIIGpGHa4=_H4C21Ns3YQ4UbW4SWA0aERPa8eOI82dwcWFTqg8jDnyAZAmauimGqlPjYKT0qe1AbWP46zFVe5CKhq+s3iOHKw==';

test('decodes a QR code from the fake camera stream', async ({ page }) => {
const pageErrors = [];

page.on('pageerror', (error) => {
pageErrors.push(error.message);
});

await page.goto('/playwright/fixtures/qr-camera-harness.html');

await page.waitForFunction(() => window.__scanState?.result || window.__scanState?.error);

const scanState = await page.evaluate(() => window.__scanState);

expect(scanState.error).toBeNull();
expect(scanState.result).toBe(expectedQrPayload);
expect(scanState.cornerPoints).toHaveLength(4);
expect(pageErrors).toEqual([]);
});
53 changes: 53 additions & 0 deletions playwright/support/static-server.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');

const rootDir = path.resolve(__dirname, '..', '..');
const port = Number(process.env.PORT || 4173);

const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
};

const server = http.createServer((request, response) => {
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
let filePath = path.normalize(path.join(rootDir, decodeURIComponent(requestUrl.pathname)));

if (!filePath.startsWith(rootDir)) {
response.writeHead(403);
response.end('Forbidden');
return;
}
Comment on lines +21 to +28

if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}

fs.readFile(filePath, (error, fileBuffer) => {
if (error) {
response.writeHead(error.code === 'ENOENT' ? 404 : 500, {
'Content-Type': 'text/plain; charset=utf-8',
});
response.end(error.code === 'ENOENT' ? 'Not found' : 'Internal server error');
return;
}

response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': mimeTypes[path.extname(filePath)] || 'application/octet-stream',
});
response.end(fileBuffer);
});
});

server.listen(port, '127.0.0.1', () => {
process.stdout.write(`Static server listening on http://127.0.0.1:${port}\n`);
});
Loading