diff --git a/.github/workflows/release-branch.yml b/.github/workflows/release-branch.yml new file mode 100644 index 0000000..ffaf859 --- /dev/null +++ b/.github/workflows/release-branch.yml @@ -0,0 +1,70 @@ +name: Branch build and release + +on: + push: + branches: + - '**' + +permissions: + contents: write + +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 + + - 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 diff --git a/README.md b/README.md index e8fac17..bb9d80d 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/package.json b/package.json index ffae9ba..686c8e5 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..c3098df --- /dev/null +++ b/playwright.config.js @@ -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, + }, +}; diff --git a/playwright/fixtures/VideoOfQrCode.mjpeg b/playwright/fixtures/VideoOfQrCode.mjpeg new file mode 100644 index 0000000..e6d3d41 Binary files /dev/null and b/playwright/fixtures/VideoOfQrCode.mjpeg differ diff --git a/playwright/fixtures/qr-camera-harness.html b/playwright/fixtures/qr-camera-harness.html new file mode 100644 index 0000000..3bb98b0 --- /dev/null +++ b/playwright/fixtures/qr-camera-harness.html @@ -0,0 +1,86 @@ + + +
+ +booting
+ + + + + + + diff --git a/playwright/qr-recognition.integration.spec.js b/playwright/qr-recognition.integration.spec.js new file mode 100644 index 0000000..2c04596 --- /dev/null +++ b/playwright/qr-recognition.integration.spec.js @@ -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([]); +}); diff --git a/playwright/support/static-server.cjs b/playwright/support/static-server.cjs new file mode 100644 index 0000000..a60f78a --- /dev/null +++ b/playwright/support/static-server.cjs @@ -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; + } + + 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`); +});