Skip to content

Commit 290bb23

Browse files
committed
Add session reliability browser validation
1 parent 4434c05 commit 290bb23

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: session-reliability-e2e
2+
3+
on:
4+
push:
5+
branches: [validation/consumer-e2e]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
browser-recovery:
12+
runs-on: ubuntu-latest
13+
env:
14+
TARGET_REVISION: e6abc1b1cd3a7558851c229eea6f3e0921a79aa2
15+
VITE_SOFTWARE_REVISION: e6abc1b1cd3a7558851c229eea6f3e0921a79aa2
16+
steps:
17+
- uses: actions/checkout@v7
18+
with:
19+
ref: ${{ env.TARGET_REVISION }}
20+
- uses: actions/setup-node@v6
21+
with:
22+
node-version: 22
23+
- run: corepack enable
24+
- run: pnpm install --frozen-lockfile
25+
- name: Verify exact checkout
26+
run: test "$(git rev-parse HEAD)" = "${TARGET_REVISION}"
27+
- name: Generate deterministic strike
28+
run: |
29+
python - <<'PY'
30+
import math, os, random, wave
31+
sr = 48_000
32+
duration = 6.0
33+
onset = 1.0
34+
random.seed(0x45525232)
35+
path = os.path.join(os.environ['RUNNER_TEMP'], 'session-recovery.wav')
36+
with wave.open(path, 'wb') as wav:
37+
wav.setnchannels(1); wav.setsampwidth(2); wav.setframerate(sr)
38+
frames = bytearray()
39+
for i in range(round(sr * duration)):
40+
t = i / sr
41+
value = random.uniform(-0.00012, 0.00012)
42+
if t >= onset:
43+
dt = t - onset
44+
value += 0.45 * math.exp(-dt / 1.20) * math.cos(2 * math.pi * 440 * dt)
45+
value += 0.24 * math.exp(-dt / 0.70) * math.cos(2 * math.pi * 997 * dt)
46+
value += 0.12 * math.exp(-dt / 0.38) * math.cos(2 * math.pi * 2413 * dt)
47+
value = max(-0.90, min(0.90, value))
48+
frames += int(round(value * 32767)).to_bytes(2, 'little', signed=True)
49+
wav.writeframes(frames)
50+
PY
51+
- run: pnpm build
52+
- name: Install browser driver
53+
run: |
54+
mkdir -p "${RUNNER_TEMP}/session-reliability"
55+
cd "${RUNNER_TEMP}/session-reliability"
56+
npm init -y >/dev/null
57+
npm install --no-save --package-lock=false puppeteer-core@25.4.0
58+
node -e "const p=require('puppeteer-core/package.json'); if(p.version!=='25.4.0') process.exit(1)"
59+
- name: Exercise permission and suspended-audio recovery
60+
run: |
61+
set -euo pipefail
62+
CHROME="$(command -v google-chrome || command -v chromium || command -v chromium-browser || true)"
63+
test -n "${CHROME}"
64+
pnpm --filter @everything-rings/web exec vite preview --host 127.0.0.1 --port 4173 --strictPort >"${RUNNER_TEMP}/session-vite.log" 2>&1 &
65+
SERVER_PID=$!
66+
trap 'kill "${SERVER_PID}" 2>/dev/null || true' EXIT
67+
for _ in $(seq 1 40); do curl --fail --silent http://127.0.0.1:4173/ >/dev/null && break; sleep 0.25; done
68+
cat >"${RUNNER_TEMP}/session-reliability/test.mjs" <<'JS'
69+
import puppeteer from 'puppeteer-core';
70+
import fs from 'node:fs';
71+
72+
const executablePath = process.env.CHROME;
73+
const baseArgs = ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--autoplay-policy=no-user-gesture-required'];
74+
const pageErrors = [];
75+
76+
const deniedBrowser = await puppeteer.launch({ executablePath, headless: true, args: baseArgs });
77+
try {
78+
const page = await deniedBrowser.newPage();
79+
page.on('pageerror', error => pageErrors.push(`denied:${error.message}`));
80+
await page.evaluateOnNewDocument(() => {
81+
const mediaDevices = navigator.mediaDevices;
82+
if (mediaDevices !== undefined) {
83+
mediaDevices.getUserMedia = async () => { throw new DOMException('Permission denied', 'NotAllowedError'); };
84+
}
85+
});
86+
await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle0', timeout: 30_000 });
87+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'START LISTENING')?.click());
88+
await page.waitForFunction(() => document.body.innerText.includes('Microphone access is blocked.'), { timeout: 5_000 });
89+
} finally {
90+
await deniedBrowser.close();
91+
}
92+
93+
const browser = await puppeteer.launch({
94+
executablePath,
95+
headless: true,
96+
args: [...baseArgs, '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-audio-capture=${process.env.RUNNER_TEMP}/session-recovery.wav`],
97+
});
98+
let suspendedPlaybackRecovered = false;
99+
let suspendedInstrumentRecovered = false;
100+
try {
101+
const page = await browser.newPage();
102+
page.on('pageerror', error => pageErrors.push(`recovery:${error.message}`));
103+
await page.evaluateOnNewDocument(() => {
104+
const NativeAudioContext = window.AudioContext;
105+
const contexts = [];
106+
Object.defineProperty(window, '__ER_CONTEXTS__', { value: contexts });
107+
window.AudioContext = new Proxy(NativeAudioContext, {
108+
construct(target, args, newTarget) {
109+
const context = Reflect.construct(target, args, newTarget);
110+
contexts.push(context);
111+
return context;
112+
},
113+
});
114+
});
115+
await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle0', timeout: 30_000 });
116+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'START LISTENING')?.click());
117+
await page.waitForFunction(() => document.body.innerText.includes('We estimated 3 audible resonances.'), { timeout: 15_000, polling: 100 });
118+
119+
const suspend = () => page.evaluate(async () => {
120+
const context = window.__ER_CONTEXTS__?.[0];
121+
if (!(context instanceof AudioContext)) throw new Error('tracked AudioContext missing');
122+
await context.suspend();
123+
return context.state;
124+
});
125+
const waitRunning = () => page.waitForFunction(() => window.__ER_CONTEXTS__?.[0]?.state === 'running', { timeout: 5_000, polling: 50 });
126+
127+
if (await suspend() !== 'suspended') throw new Error('playback context did not suspend');
128+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'HEAR ORIGINAL')?.click());
129+
await waitRunning();
130+
suspendedPlaybackRecovered = true;
131+
132+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'PLAY IT')?.click());
133+
await page.waitForSelector('.consumer-keyboard button[aria-label="C4"]');
134+
if (await suspend() !== 'suspended') throw new Error('instrument context did not suspend');
135+
await page.focus('.consumer-keyboard button[aria-label="C4"]');
136+
await page.keyboard.press('Enter');
137+
await waitRunning();
138+
suspendedInstrumentRecovered = true;
139+
140+
const text = await page.evaluate(() => document.body.innerText);
141+
if (text.includes('Audio output is paused.')) throw new Error('playback recovery surfaced a false failure');
142+
} finally {
143+
await browser.close();
144+
}
145+
146+
if (pageErrors.length > 0) throw new Error(pageErrors.join('\n'));
147+
const report = {
148+
targetRevision: process.env.TARGET_REVISION,
149+
permissionDeniedCopy: true,
150+
suspendedPlaybackRecovered,
151+
suspendedInstrumentRecovered,
152+
pageErrors: pageErrors.length,
153+
};
154+
fs.writeFileSync(`${process.env.RUNNER_TEMP}/session-reliability-report.json`, JSON.stringify(report, null, 2));
155+
console.log(JSON.stringify(report, null, 2));
156+
JS
157+
CHROME="${CHROME}" RUNNER_TEMP="${RUNNER_TEMP}" TARGET_REVISION="${TARGET_REVISION}" node "${RUNNER_TEMP}/session-reliability/test.mjs"
158+
- uses: actions/upload-artifact@v4
159+
with:
160+
name: session-reliability-e2e
161+
path: |
162+
${{ runner.temp }}/session-reliability-report.json
163+
${{ runner.temp }}/session-vite.log

0 commit comments

Comments
 (0)