|
| 1 | +name: browser-mic-e2e |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + branches: |
| 6 | + - stress/browser-mic-e2e-2026-08-15 |
| 7 | + |
| 8 | +permissions: |
| 9 | + contents: read |
| 10 | + |
| 11 | +jobs: |
| 12 | + chromium-fake-microphone: |
| 13 | + runs-on: ubuntu-24.04 |
| 14 | + timeout-minutes: 25 |
| 15 | + env: |
| 16 | + VITE_SOFTWARE_REVISION: 447f98dd511201367d6051704e10607d645735dd |
| 17 | + steps: |
| 18 | + - uses: actions/checkout@v7 |
| 19 | + - uses: actions/setup-node@v6 |
| 20 | + with: |
| 21 | + node-version: 22 |
| 22 | + - run: corepack enable |
| 23 | + - run: pnpm install --frozen-lockfile |
| 24 | + - run: pnpm build |
| 25 | + - name: Generate deterministic fake microphone impact WAV |
| 26 | + shell: bash |
| 27 | + run: | |
| 28 | + python - <<'PY' |
| 29 | + import math, random, struct, wave |
| 30 | + rate = 48000 |
| 31 | + seconds = 5.0 |
| 32 | + onset = 0.85 |
| 33 | + rng = random.Random(20260815) |
| 34 | + modes = [(440.0, 0.55, 1.20), (997.0, 0.31, 0.70), (2413.0, 0.17, 0.38)] |
| 35 | + path = '/tmp/everything-rings-fake-impact.wav' |
| 36 | + with wave.open(path, 'wb') as out: |
| 37 | + out.setnchannels(1) |
| 38 | + out.setsampwidth(2) |
| 39 | + out.setframerate(rate) |
| 40 | + frames = bytearray() |
| 41 | + for i in range(round(rate * seconds)): |
| 42 | + t = i / rate |
| 43 | + value = (rng.random() * 2 - 1) * 0.00025 |
| 44 | + if t >= onset: |
| 45 | + dt = t - onset |
| 46 | + value += sum(a * math.exp(-dt / tau) * math.sin(2 * math.pi * f * dt) for f, a, tau in modes) |
| 47 | + value = max(-0.92, min(0.92, value)) |
| 48 | + frames += struct.pack('<h', round(value * 32767)) |
| 49 | + out.writeframes(frames) |
| 50 | + print(path) |
| 51 | + PY |
| 52 | + - name: Install isolated Playwright Chromium |
| 53 | + shell: bash |
| 54 | + run: | |
| 55 | + set -euo pipefail |
| 56 | + mkdir -p /tmp/everything-rings-mic-e2e |
| 57 | + cd /tmp/everything-rings-mic-e2e |
| 58 | + npm init -y >/dev/null |
| 59 | + npm install --save-exact playwright@1.55.0 |
| 60 | + npx playwright install --with-deps chromium |
| 61 | + - name: Exercise microphone to exported evidence end to end |
| 62 | + shell: bash |
| 63 | + run: | |
| 64 | + set -euo pipefail |
| 65 | + pnpm --filter @everything-rings/web exec vite preview --host 127.0.0.1 --port 4173 --strictPort >/tmp/everything-rings-mic-e2e-vite.log 2>&1 & |
| 66 | + server_pid=$! |
| 67 | + trap 'kill "$server_pid" 2>/dev/null || true' EXIT |
| 68 | + for _ in $(seq 1 40); do |
| 69 | + if curl --fail --silent http://127.0.0.1:4173 >/dev/null; then break; fi |
| 70 | + sleep 0.25 |
| 71 | + done |
| 72 | + cat >/tmp/everything-rings-mic-e2e/e2e.mjs <<'NODE' |
| 73 | + import fs from 'node:fs'; |
| 74 | + import { chromium } from 'playwright'; |
| 75 | +
|
| 76 | + const revision = '447f98dd511201367d6051704e10607d645735dd'; |
| 77 | + const origin = 'http://127.0.0.1:4173'; |
| 78 | + const browser = await chromium.launch({ |
| 79 | + headless: true, |
| 80 | + args: [ |
| 81 | + '--use-fake-ui-for-media-stream', |
| 82 | + '--use-fake-device-for-media-stream', |
| 83 | + '--use-file-for-fake-audio-capture=/tmp/everything-rings-fake-impact.wav', |
| 84 | + '--autoplay-policy=no-user-gesture-required', |
| 85 | + ], |
| 86 | + }); |
| 87 | + const context = await browser.newContext({ acceptDownloads: true }); |
| 88 | + await context.grantPermissions(['microphone'], { origin }); |
| 89 | + const page = await context.newPage(); |
| 90 | + const pageErrors = []; |
| 91 | + page.on('pageerror', (error) => pageErrors.push(String(error))); |
| 92 | + await page.goto(`${origin}/?lab=1`, { waitUntil: 'networkidle' }); |
| 93 | +
|
| 94 | + const body = await page.locator('body').innerText(); |
| 95 | + if (!body.includes(`software revision ${revision}`)) throw new Error('lab does not expose frozen revision'); |
| 96 | +
|
| 97 | + await page.getByLabel('specimen ID').fill('fake-mic-positive-control'); |
| 98 | + await page.getByLabel('object').fill('synthetic modal impact'); |
| 99 | + await page.getByLabel('material').selectOption('metal'); |
| 100 | + await page.getByLabel('microphone distance').locator('input').fill('20'); |
| 101 | + await page.getByLabel('striker').fill('deterministic fake microphone waveform'); |
| 102 | + await page.getByLabel('strike location').fill('synthetic onset'); |
| 103 | + await page.getByLabel('support condition').fill('CI positive control'); |
| 104 | +
|
| 105 | + const arm = page.getByRole('button', { name: 'ARM MICROPHONE' }); |
| 106 | + if (await arm.isDisabled()) throw new Error('ARM MICROPHONE unexpectedly disabled'); |
| 107 | + await arm.click(); |
| 108 | +
|
| 109 | + await page.locator('.status-success').waitFor({ state: 'visible', timeout: 20000 }); |
| 110 | + const modeRows = page.locator('[aria-label="Estimated acoustic modes"] .mode-row:not(.mode-head)'); |
| 111 | + const modeCount = await modeRows.count(); |
| 112 | + if (modeCount < 3) throw new Error(`expected at least 3 modes after microphone analysis, got ${modeCount}`); |
| 113 | + const modeText = await modeRows.allInnerTexts(); |
| 114 | + const frequencies = modeText.map((text) => Number.parseFloat(text)); |
| 115 | + for (const expected of [440, 997, 2413]) { |
| 116 | + if (!frequencies.some((actual) => Number.isFinite(actual) && Math.abs(actual - expected) <= Math.max(5, expected * 0.01))) { |
| 117 | + throw new Error(`expected resonance near ${expected} Hz; observed ${JSON.stringify(frequencies)}`); |
| 118 | + } |
| 119 | + } |
| 120 | +
|
| 121 | + const downloadPromise = page.waitForEvent('download'); |
| 122 | + await page.getByRole('button', { name: 'EXPORT EVIDENCE' }).click(); |
| 123 | + const download = await downloadPromise; |
| 124 | + const exportPath = '/tmp/everything-rings-mic-e2e/evidence.json'; |
| 125 | + await download.saveAs(exportPath); |
| 126 | + const evidence = JSON.parse(fs.readFileSync(exportPath, 'utf8')); |
| 127 | + if (evidence.schemaVersion !== 5 || evidence.evidenceContractVersion !== 'validation-evidence-5') { |
| 128 | + throw new Error('exported evidence has wrong schema contract'); |
| 129 | + } |
| 130 | + if (evidence.softwareRevision !== revision) throw new Error(`wrong exported revision ${evidence.softwareRevision}`); |
| 131 | + if (evidence.attemptCount !== 1 || evidence.attempts?.length !== 1) throw new Error('expected one retained qualified attempt'); |
| 132 | + if (evidence.attempts[0]?.analysis?.status !== 'success') throw new Error('exported microphone attempt is not analytically successful'); |
| 133 | + if (evidence.rawMicrophoneSamplesIncluded !== false) throw new Error('raw PCM invariant violated'); |
| 134 | + if (pageErrors.length > 0) throw new Error(`uncaught page errors: ${pageErrors.join(' | ')}`); |
| 135 | + console.log(JSON.stringify({ modeCount, frequencies, quality: evidence.attempts[0].quality }, null, 2)); |
| 136 | + await browser.close(); |
| 137 | + NODE |
| 138 | + cd /tmp/everything-rings-mic-e2e |
| 139 | + node e2e.mjs | tee /tmp/everything-rings-mic-e2e-result.json |
| 140 | + - uses: actions/upload-artifact@v4 |
| 141 | + if: always() |
| 142 | + with: |
| 143 | + name: browser-mic-e2e-report |
| 144 | + path: | |
| 145 | + /tmp/everything-rings-mic-e2e-result.json |
| 146 | + /tmp/everything-rings-mic-e2e/evidence.json |
| 147 | + if-no-files-found: warn |
0 commit comments