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 @@ + + + + + QR scanner integration harness + + +

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`);
+});