Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions e2e/tests/session-guidance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { expect, stackTest } from '../fixtures/stack';
import { loginAttendeeWithTicket } from '../fixtures/auth';
import { SESSION_ES, TICKETS } from '../fixtures/test-data';
import { requireDirectDb, withSessionStatus } from '../fixtures/db';
import {
expectMediaContinuity,
installMediaProbe,
mediaProbeSnapshot,
} from '../helpers/media-probe';

/**
* UX-COPY-01 (#142): the listening guidance end to end, in real browsers
* against the real stack.
*
* Test 1 proves the pre-room surface: while the doors are closed the
* attendee sees the guidance disclosure next to the waiting card, opens it,
* and reads the full guidance (intention, volume, balance, camera/mic).
*
* Test 2 is the media-continuity guard from the issue: opening and closing
* the guidance inside the live room must not disturb the audio/scene
* pipeline. It reuses the TAP media probe and skips precisely when LiveKit
* is unreachable, like the canonical continuity suite. It also proves the
* disclosure sits next to the volume controls without overlapping them.
*/

const LIVEKIT_URL = process.env.E2E_LIVEKIT_URL ?? 'ws://localhost:7880';

const ATTENDEE_A = {
name: 'E2E Attendee',
email: 'e2e.attendee@altermundi.net',
code: TICKETS.esIssuedA,
} as const;

async function livekitReachable(): Promise<boolean> {
const httpUrl = LIVEKIT_URL.replace(/^ws/, 'http');
try {
const response = await fetch(httpUrl, { signal: AbortSignal.timeout(3000) });
return response.ok;
} catch {
return false;
}
}

stackTest.describe('session listening guidance (#142)', () => {
stackTest('waiting attendee can read the guidance before the doors open', async ({
browser,
}, testInfo) => {
stackTest.slow();
const db = requireDirectDb(testInfo);
// Doors closed: the entry gate holds the attendee in the WAITING
// surface, where the guidance lives next to the waiting card.
await withSessionStatus(db, SESSION_ES.id, 'SCHEDULED', async () => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await loginAttendeeWithTicket(page, ATTENDEE_A);
await page.waitForURL(`**/session/${SESSION_ES.id}`);

const guidance = page.getByTestId('session-guidance');
await expect(guidance).toBeVisible({ timeout: 30_000 });

const toggle = guidance.getByRole('button');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
// The label is always visible text, never an icon-only control.
await expect(toggle).toContainText(/Cómo funciona la escucha|How listening works/);

await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(
guidance.getByText(/buscar una pregunta|look for a question/),
).toBeVisible();
await expect(
guidance.getByText(/volumen general controla|Overall volume controls/),
).toBeVisible();
await expect(
guidance.getByText(/balance elige|balance chooses/),
).toBeVisible();
await expect(
guidance.getByText(/Apagar la cámara no apaga|Turning off your camera/),
).toBeVisible();

// Keyboard path: close with Enter, focus stays on the toggle.
await toggle.focus();
await page.keyboard.press('Enter');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(toggle).toBeFocused();
} finally {
await context.close();
}
});
});

stackTest('opening the guidance in the room never disturbs the audio/scene pipeline', async ({
browser,
}, testInfo) => {
testInfo.skip(
!(await livekitReachable()),
`LiveKit not reachable at ${LIVEKIT_URL} — start the dev server (see e2e/README.md) or set E2E_LIVEKIT_URL`,
);
stackTest.slow();
const db = requireDirectDb(testInfo);
await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await installMediaProbe(page);
await loginAttendeeWithTicket(page, ATTENDEE_A);
await page.waitForURL(`**/session/${SESSION_ES.id}`);
await expect(page.getByTestId('connection-state')).toHaveAttribute(
'data-state',
'connected',
{ timeout: 30_000 },
);
await page.getByRole('button', { name: /Start audio|Iniciar audio/i }).click();

// Baseline: let the media pipeline settle before touching the
// guidance (same stable-read discipline as the canonical
// continuity suite: counters frozen for 4 consecutive reads).
let baseline = await mediaProbeSnapshot(page);
let stableReads = 0;
for (let attempt = 0; attempt < 20 && stableReads < 4; attempt += 1) {
await page.waitForTimeout(250);
const current = await mediaProbeSnapshot(page);
const unchanged =
current.audioElements === baseline.audioElements &&
current.videoElements === baseline.videoElements &&
current.playCalls === baseline.playCalls &&
current.mediaElementsAttached === baseline.mediaElementsAttached &&
current.mediaElementsRemoved === baseline.mediaElementsRemoved;
stableReads = unchanged ? stableReads + 1 : 0;
baseline = current;
}

const guidance = page.getByTestId('session-guidance');
const toggle = guidance.getByRole('button');

// The disclosure sits right above the volume controls it
// explains, without overlapping them or the scene.
const toggleBox = await toggle.boundingBox();
const volumeBox = await page.locator('#room-master-volume').boundingBox();
expect(toggleBox, 'guidance toggle has no layout box').not.toBeNull();
expect(volumeBox, 'master volume has no layout box').not.toBeNull();
expect(toggleBox!.y + toggleBox!.height).toBeLessThanOrEqual(volumeBox!.y + 2);
expect(toggleBox!.x).toBeGreaterThanOrEqual(0);
expect(toggleBox!.x + toggleBox!.width).toBeLessThanOrEqual(
page.viewportSize()!.width + 1,
);

// Exercise the disclosure: open, read, close.
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(
guidance.getByText(/Apagar la cámara no apaga|Turning off your camera/),
).toBeVisible();
await page.waitForTimeout(500);
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await page.waitForTimeout(2_500);

const afterGuidance = await mediaProbeSnapshot(page);
expectMediaContinuity(baseline, afterGuidance, {
// Headless Firefox keeps LiveKit's global autoplay-unlock
// listener active and retries resume() on every user gesture,
// even while sockets, peers, media elements and play() stay
// untouched. That browser behavior cannot be attributed to
// this disclosure; all structural media invariants remain strict.
ignoreAmbientAudioContextResumes: testInfo.project.name === 'firefox',
});
} finally {
await context.close();
}
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion src/app/session/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { AudioProvider, useAudio } from "@/context/AudioContext";
import { useLocale } from "@/context/LocaleContext";
import HandRaiseButton from "@/components/session/HandRaiseButton";
import SessionContributions from "@/components/session/SessionContributions";
import SessionGuidance from "@/components/session/SessionGuidance";
import StageLayout, { type StagePublisherView } from "@/components/session/StageLayout";
import ThumbnailSender from "@/components/session/ThumbnailSender";
import ThumbnailTapestry from "@/components/session/ThumbnailTapestry";
Expand Down Expand Up @@ -1068,6 +1069,11 @@ function SessionRoom() {

<ThumbnailTapestry sessionId={id} />

{/* Listening guidance: a quiet disclosure above the
controls it explains. Presentational only — opening it
never remounts the Room or touches audio state. */}
<SessionGuidance copy={copy.session.guidance} className="w-full max-w-sm" />

{/* Volume + Mix controls */}
<div className="w-full max-w-sm space-y-5">
<div>
Expand Down Expand Up @@ -1369,7 +1375,7 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) {
}).format(new Date(entry.session.scheduledAt));
return (
<main className="event-shell">
<div className="relative z-10 flex min-h-screen items-center justify-center px-4">
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center gap-4 px-4 py-8">
<section role="status" aria-live="polite" className="event-card w-full max-w-md text-center">
<div className="terminal-state__icon text-[var(--lime)]">&#10022;</div>
<p className="font-mono text-xs uppercase tracking-[0.12em] text-[var(--lime)]">
Expand All @@ -1390,6 +1396,9 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) {
</p>
)}
</section>
{/* Guidance waits outside the live region: the disclosure
toggling must not be announced as a door-state change. */}
<SessionGuidance copy={copy.session.guidance} className="w-full max-w-md" />
</div>
</main>
);
Expand Down
64 changes: 64 additions & 0 deletions src/components/session/SessionGuidance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use client";

import { useId, useState } from "react";
import type { Messages } from "@/lib/i18n";

type SessionGuidanceCopy = Messages["session"]["guidance"];

interface SessionGuidanceProps {
copy: SessionGuidanceCopy;
className?: string;
}

/**
* Brief, bilingual-by-locale listener guidance: why the room invites a
* question, what the master volume and Beacon/Session balance do, and the
* independence of camera and microphone. Purely presentational — it owns no
* media, network, or room state, so opening or closing it can never remount
* the Room, touch an AudioContext, or call a device handler. The disclosure
* starts closed and stays quiet next to the scene; the native <button>
* carries keyboard support and focus retention, and the `hidden` panel adds
* no animation (prefers-reduced-motion safe by construction).
*/
export default function SessionGuidance({ copy, className }: SessionGuidanceProps) {
const [expanded, setExpanded] = useState(false);
const panelId = useId();

return (
<div className={className} data-testid="session-guidance">
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((value) => !value)}
className="flex min-h-11 w-full items-center justify-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-alt)]/60 px-4 py-2 text-xs font-medium uppercase tracking-[0.08em] text-[var(--text-secondary)] transition-colors hover:border-[var(--gold)]/40 hover:text-[var(--cream)] motion-reduce:transition-none"
>
<svg
className={`h-3.5 w-3.5 shrink-0 text-[var(--gold)] motion-safe:transition-transform motion-reduce:transition-none ${expanded ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
<span>{copy.label}</span>
</button>
<div
id={panelId}
hidden={!expanded}
className="mt-3 rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-alt)]/80 px-4 py-4 text-left"
>
<p className="text-sm leading-6 text-[var(--cream)]">{copy.intention}</p>
<ul className="mt-3 list-none space-y-2 text-xs leading-5 text-[var(--text-secondary)]">
<li>{copy.volume}</li>
<li>{copy.balance}</li>
<li>{copy.balanceFullBeacon}</li>
<li>{copy.cameraMic}</li>
<li>{copy.control}</li>
</ul>
</div>
</div>
);
}
Loading
Loading