Skip to content

Commit be3ed98

Browse files
committed
Add consumer end-to-end validation
1 parent 9f27f94 commit be3ed98

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

‎.github/workflows/consumer-e2e.yml‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
name: consumer-e2e
2+
3+
on:
4+
push:
5+
branches: [validation/consumer-e2e]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
consumer-flow:
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 15
14+
env:
15+
TARGET_REVISION: 5de7e472982da3451c1b43d7a421701648105fa2
16+
VITE_SOFTWARE_REVISION: 5de7e472982da3451c1b43d7a421701648105fa2
17+
steps:
18+
- uses: actions/checkout@v7
19+
with:
20+
ref: 5de7e472982da3451c1b43d7a421701648105fa2
21+
- uses: actions/setup-node@v6
22+
with:
23+
node-version: 22
24+
- run: corepack enable
25+
- run: pnpm install --frozen-lockfile
26+
- name: Verify exact checkout
27+
run: test "$(git rev-parse HEAD)" = "${TARGET_REVISION}"
28+
- name: Generate deterministic strike
29+
run: |
30+
python - <<'PY'
31+
import math, os, random, wave
32+
sr = 48_000
33+
duration = 6.0
34+
onset = 1.0
35+
random.seed(0x45524331)
36+
path = os.path.join(os.environ['RUNNER_TEMP'], 'consumer-loop.wav')
37+
with wave.open(path, 'wb') as wav:
38+
wav.setnchannels(1)
39+
wav.setsampwidth(2)
40+
wav.setframerate(sr)
41+
frames = bytearray()
42+
for i in range(round(sr * duration)):
43+
t = i / sr
44+
value = random.uniform(-0.00012, 0.00012)
45+
if t >= onset:
46+
dt = t - onset
47+
value += 0.45 * math.exp(-dt / 1.20) * math.cos(2 * math.pi * 440 * dt)
48+
value += 0.24 * math.exp(-dt / 0.70) * math.cos(2 * math.pi * 997 * dt)
49+
value += 0.12 * math.exp(-dt / 0.38) * math.cos(2 * math.pi * 2413 * dt)
50+
value = max(-0.90, min(0.90, value))
51+
frames += int(round(value * 32767)).to_bytes(2, 'little', signed=True)
52+
wav.writeframes(frames)
53+
print(path)
54+
PY
55+
- run: pnpm build
56+
- name: Install browser driver
57+
run: |
58+
mkdir -p "${RUNNER_TEMP}/consumer-e2e"
59+
cd "${RUNNER_TEMP}/consumer-e2e"
60+
npm init -y >/dev/null
61+
npm install --no-save --package-lock=false puppeteer-core@24.31.0
62+
node -e "const p=require('puppeteer-core/package.json'); if(p.version!=='24.31.0') process.exit(1)"
63+
- name: Exercise consumer flow
64+
run: |
65+
set -euo pipefail
66+
CHROME="$(command -v google-chrome || command -v chromium || command -v chromium-browser || true)"
67+
test -n "${CHROME}"
68+
pnpm --filter @everything-rings/web exec vite preview --host 127.0.0.1 --port 4173 --strictPort >"${RUNNER_TEMP}/consumer-vite.log" 2>&1 &
69+
SERVER_PID=$!
70+
trap 'kill "${SERVER_PID}" 2>/dev/null || true' EXIT
71+
for _ in $(seq 1 40); do
72+
curl --fail --silent http://127.0.0.1:4173/ >/dev/null && break
73+
sleep 0.25
74+
done
75+
cat >"${RUNNER_TEMP}/consumer-e2e/test.mjs" <<'JS'
76+
import puppeteer from 'puppeteer-core';
77+
import fs from 'node:fs';
78+
79+
const browser = await puppeteer.launch({
80+
executablePath: process.env.CHROME,
81+
headless: true,
82+
args: [
83+
'--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
84+
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream',
85+
`--use-file-for-fake-audio-capture=${process.env.RUNNER_TEMP}/consumer-loop.wav`,
86+
'--autoplay-policy=no-user-gesture-required',
87+
],
88+
});
89+
const page = await browser.newPage();
90+
const pageErrors = [];
91+
page.on('pageerror', error => pageErrors.push(error.message));
92+
93+
async function button(label) {
94+
return page.evaluateHandle(text => {
95+
const match = [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === text);
96+
if (!(match instanceof HTMLButtonElement)) throw new Error(`${text} missing`);
97+
return match;
98+
}, label);
99+
}
100+
101+
async function clickButton(label) {
102+
await page.evaluate(text => {
103+
const match = [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === text);
104+
if (!(match instanceof HTMLButtonElement) || match.disabled) throw new Error(`${text} unavailable`);
105+
match.click();
106+
}, label);
107+
}
108+
109+
try {
110+
await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle0', timeout: 30_000 });
111+
await clickButton('START LISTENING');
112+
await page.waitForFunction(
113+
() => document.body.innerText.includes('We estimated 3 audible resonances.'),
114+
{ timeout: 15_000, polling: 100 },
115+
);
116+
117+
const reveal = await page.evaluate(() => ({
118+
text: document.body.innerText,
119+
dna: document.querySelector('.dna-view') !== null,
120+
originalDisabled: [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'HEAR ORIGINAL')?.disabled ?? true,
121+
reconstructionDisabled: [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'HEAR RECONSTRUCTION')?.disabled ?? true,
122+
}));
123+
if (!reveal.dna) throw new Error('Acoustic DNA missing');
124+
if (!reveal.text.includes('stable resonance supported by this recording')) throw new Error('truth-qualified reveal copy missing');
125+
if (reveal.originalDisabled) throw new Error('original playback unavailable after successful capture');
126+
if (reveal.reconstructionDisabled) throw new Error('reconstruction playback unavailable after successful analysis');
127+
128+
await clickButton('HEAR ORIGINAL');
129+
await new Promise(resolve => setTimeout(resolve, 150));
130+
await clickButton('HEAR RECONSTRUCTION');
131+
await new Promise(resolve => setTimeout(resolve, 150));
132+
133+
await page.waitForFunction(() => {
134+
const match = [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'PLAY IT');
135+
return match instanceof HTMLButtonElement && !match.disabled;
136+
}, { timeout: 5_000, polling: 100 });
137+
await clickButton('PLAY IT');
138+
139+
const keyboard = await page.evaluate(() => ({
140+
keyCount: document.querySelectorAll('.consumer-keyboard button').length,
141+
expanded: [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'HIDE KEYS')?.getAttribute('aria-expanded'),
142+
c4: document.querySelector('.consumer-keyboard button[aria-label="C4"]') !== null,
143+
d4: document.querySelector('.consumer-keyboard button[aria-label="D4"]') !== null,
144+
}));
145+
if (keyboard.keyCount !== 13 || keyboard.expanded !== 'true' || !keyboard.c4 || !keyboard.d4) {
146+
throw new Error(`keyboard contract failed: ${JSON.stringify(keyboard)}`);
147+
}
148+
149+
await page.focus('.consumer-keyboard button[aria-label="C4"]');
150+
await page.keyboard.press('Enter');
151+
await page.$eval('.consumer-keyboard button[aria-label="D4"]', node => {
152+
node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }));
153+
});
154+
155+
await clickButton('HIDE KEYS');
156+
const closed = await page.evaluate(() => ({
157+
keyboardPresent: document.querySelector('.consumer-keyboard') !== null,
158+
expanded: [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === 'PLAY IT')?.getAttribute('aria-expanded'),
159+
}));
160+
if (closed.keyboardPresent || closed.expanded !== 'false') throw new Error(`keyboard did not close cleanly: ${JSON.stringify(closed)}`);
161+
if (pageErrors.length > 0) throw new Error(pageErrors.join('\n'));
162+
163+
const report = {
164+
targetRevision: process.env.TARGET_REVISION,
165+
revealCopy: 'estimated audible resonances',
166+
originalPlayback: true,
167+
reconstructionPlayback: true,
168+
playableKeyCount: keyboard.keyCount,
169+
keyboardActivation: true,
170+
pointerActivation: true,
171+
closeSilencesInstrumentPath: true,
172+
pageErrors: pageErrors.length,
173+
};
174+
console.log(JSON.stringify(report, null, 2));
175+
fs.writeFileSync(`${process.env.RUNNER_TEMP}/consumer-e2e-report.json`, JSON.stringify(report, null, 2));
176+
} finally {
177+
await browser.close();
178+
}
179+
JS
180+
CHROME="${CHROME}" RUNNER_TEMP="${RUNNER_TEMP}" TARGET_REVISION="${TARGET_REVISION}" node "${RUNNER_TEMP}/consumer-e2e/test.mjs"
181+
- uses: actions/upload-artifact@v4
182+
if: always()
183+
with:
184+
name: consumer-e2e
185+
path: |
186+
${{ runner.temp }}/consumer-e2e-report.json
187+
${{ runner.temp }}/consumer-vite.log
188+
if-no-files-found: ignore

0 commit comments

Comments
 (0)