Files
noteflow/client/e2e-native/lifecycle.spec.ts
2026-01-14 23:23:01 -05:00

722 lines
29 KiB
TypeScript

/**
* Aggressive lifecycle and event-loop stress tests for native recording flows.
*/
/// <reference path="./globals.d.ts" />
import { waitForAppReady, navigateTo, executeInApp, TestData } from './fixtures';
const SERVER_URL = 'http://127.0.0.1:50051';
const TONE = { frequency: 440, seconds: 1, sampleRate: 16000 };
const ALL_MEETING_STATES = ['created', 'recording', 'stopped', 'completed', 'error'] as const;
const MEETING_LIST_LIMIT = 200;
type MeetingSnapshot = {
id: string;
title?: string;
state?: string;
duration_seconds?: number;
created_at?: number;
};
type ListMeetingsResult = {
meetings?: MeetingSnapshot[];
};
const isErrorResult = (result: unknown): result is { error: string } => {
return Boolean(result && typeof result === 'object' && 'error' in result);
};
async function listMeetings(
states?: Array<'created' | 'recording' | 'stopped' | 'completed' | 'error'>,
limit = MEETING_LIST_LIMIT,
offset = 0
) {
const result = await executeInApp<ListMeetingsResult>({ type: 'listMeetings', states, limit, offset });
if (isErrorResult(result)) {
throw new Error(`listMeetings failed: ${result.error}`);
}
return result.meetings ?? [];
}
async function listMeetingIds(
states: Array<'created' | 'recording' | 'stopped' | 'completed' | 'error'>
): Promise<Set<string>> {
const meetings = await listMeetings(states);
return new Set(meetings.map((meeting) => meeting.id));
}
async function waitForLatestMeeting(
states: Array<'created' | 'recording' | 'stopped' | 'completed' | 'error'> = [
'created',
'recording',
],
timeoutMs = 8000,
minCreatedAt?: number,
excludeIds?: Set<string>
): Promise<MeetingSnapshot> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const meetings = await listMeetings(states);
if (meetings.length > 0) {
const sorted = [...meetings].sort(
(left, right) => (right.created_at ?? 0) - (left.created_at ?? 0)
);
const latest = minCreatedAt
? sorted.find((meeting) => {
if (excludeIds?.has(meeting.id)) {
return false;
}
return (meeting.created_at ?? 0) >= minCreatedAt;
})
: sorted.find((meeting) => !excludeIds?.has(meeting.id));
if (latest) {
return latest;
}
}
await browser.pause(250);
}
throw new Error(`No meeting found within ${timeoutMs}ms`);
}
function assertRecentMeeting(
meeting: MeetingSnapshot,
maxAgeSeconds = 15,
minCreatedAt?: number
): void {
const createdAt = meeting.created_at ?? 0;
if (minCreatedAt && createdAt < minCreatedAt) {
throw new Error(
`Latest meeting predates scenario start (created_at=${createdAt.toFixed(1)}s)`
);
}
const ageSeconds = Date.now() / 1000 - createdAt;
if (!createdAt || ageSeconds > maxAgeSeconds) {
throw new Error(`Latest meeting is too old (age=${ageSeconds.toFixed(1)}s)`);
}
}
async function createMeeting(title: string): Promise<MeetingSnapshot> {
const meeting = await executeInApp({ type: 'createMeeting', title });
if (!meeting || isErrorResult(meeting)) {
throw new Error('Failed to create meeting');
}
const meetingId = String((meeting as { id?: unknown }).id ?? '');
if (!meetingId) {
throw new Error('Meeting ID missing');
}
return { id: meetingId, title };
}
async function getMeeting(meetingId: string): Promise<MeetingSnapshot | null> {
const result = await executeInApp({
type: 'getMeeting',
meetingId,
includeSegments: true,
includeSummary: false,
});
if (!result || isErrorResult(result)) {
return null;
}
return result as MeetingSnapshot;
}
async function waitForMeetingState(
meetingId: string,
states: string[],
timeoutMs = 15000
): Promise<MeetingSnapshot> {
const startedAt = Date.now();
let meeting = await getMeeting(meetingId);
while (Date.now() - startedAt < timeoutMs) {
const state = meeting?.state;
if (state && states.includes(state)) {
return meeting;
}
await browser.pause(250);
meeting = await getMeeting(meetingId);
}
throw new Error(`Meeting ${meetingId} did not reach state: ${states.join(', ')}`);
}
async function stopMeetingIfRecording(meetingId: string): Promise<void> {
const snapshot = await getMeeting(meetingId);
if (snapshot?.state === 'recording') {
await executeInApp({ type: 'stopMeeting', meetingId });
}
}
async function startTone(
meetingId: string,
tone = TONE,
options?: { waitForRecording?: boolean }
) {
const result = await executeInApp({ type: 'startTranscriptionWithTone', meetingId, tone });
if (!result.success) {
throw new Error(`Tone injection failed: ${result.error ?? 'unknown error'}`);
}
if (options?.waitForRecording !== false) {
await waitForMeetingState(meetingId, ['recording']);
}
return result;
}
async function deleteMeeting(meetingId: string): Promise<void> {
await executeInApp({ type: 'deleteMeeting', meetingId });
}
async function ensureNoActiveRecordings() {
await executeInApp({ type: 'resetRecordingState' });
await executeInApp({ type: 'stopActiveRecordings' });
await browser.pause(1000);
const startedAt = Date.now();
while (Date.now() - startedAt < 5000) {
const recordings = await listMeetings(['recording']);
if (recordings.length === 0) {
return;
}
await browser.pause(250);
}
const recordings = await listMeetings(['recording']);
if (recordings.length > 0) {
throw new Error(`Expected no active recordings, found ${recordings.length}`);
}
}
describe('Lifecycle stress tests', () => {
const createdMeetingIds = new Set<string>();
before(async () => {
await waitForAppReady();
await browser.waitUntil(
async () => {
const hasTestApi = await browser.execute(() => Boolean(window.__NOTEFLOW_TEST_API__));
return Boolean(hasTestApi);
},
{
timeout: 15000,
timeoutMsg: 'Test API not available within 15s',
}
);
const prefsResult = await executeInApp<{ success?: boolean; error?: string; needsReload?: boolean }>({
type: 'updatePreferences',
updates: { simulate_transcription: false, skip_simulation_confirmation: true },
});
if (isErrorResult(prefsResult)) {
throw new Error(`Failed to update preferences: ${prefsResult.error}`);
}
if (prefsResult?.needsReload) {
await browser.refresh();
await waitForAppReady();
}
const e2eMode = await browser.execute(() => window.__NOTEFLOW_TEST_API__?.isE2EMode?.());
if (e2eMode !== '1' && e2eMode !== 'true') {
throw new Error('E2E mode disabled: build with VITE_E2E_MODE=1 before running native tests.');
}
const connectResult = await executeInApp({ type: 'connect', serverUrl: SERVER_URL });
if (isErrorResult(connectResult)) {
throw new Error(`Failed to connect: ${connectResult.error}`);
}
});
after(async () => {
for (const meetingId of createdMeetingIds) {
try {
await stopMeetingIfRecording(meetingId);
await deleteMeeting(meetingId);
} catch {
// best-effort cleanup
}
}
});
it('aggressively validates recording lifecycle scenarios', async function () {
this.timeout(15 * 60 * 1000);
await browser.setTimeout({ script: 10 * 60 * 1000 });
const scenarios: Array<{ name: string; run: () => Promise<void> }> = [
{
name: 'UI multiple rapid start clicks create only one active recording',
async run() {
await ensureNoActiveRecordings();
await navigateTo('/recording/new');
const existingIds = await listMeetingIds([...ALL_MEETING_STATES]);
const title = `Lifecycle UI multi-start ${TestData.generateTestId()}`;
const titleInput = await $('input[placeholder="Meeting title (optional)"]');
await titleInput.waitForDisplayed({ timeout: 5000 });
await titleInput.setValue(title);
const startButton = await $('button=Start Recording');
await startButton.waitForClickable({ timeout: 5000 });
await startButton.click();
await startButton.click();
await startButton.click();
await startButton.click();
const meeting = await waitForLatestMeeting(
[...ALL_MEETING_STATES],
15000,
undefined,
existingIds
);
assertRecentMeeting(meeting, 120);
const meetingId = meeting.id;
createdMeetingIds.add(meetingId);
if (meeting.state !== 'recording') {
await startTone(meetingId, { ...TONE, seconds: 1 });
}
const stopButton = await $('button=Stop Recording');
await stopButton.waitForClickable({ timeout: 10000 });
await stopButton.click();
await waitForMeetingState(meetingId, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] multi-start: meeting=${meetingId}`);
},
},
{
name: 'UI multiple rapid stop clicks are idempotent',
async run() {
await ensureNoActiveRecordings();
await navigateTo('/recording/new');
const existingIds = await listMeetingIds([...ALL_MEETING_STATES]);
const title = `Lifecycle UI multi-stop ${TestData.generateTestId()}`;
const titleInput = await $('input[placeholder="Meeting title (optional)"]');
await titleInput.waitForDisplayed({ timeout: 5000 });
await titleInput.setValue(title);
const startButton = await $('button=Start Recording');
await startButton.waitForClickable({ timeout: 5000 });
await startButton.click();
const meeting = await waitForLatestMeeting(
[...ALL_MEETING_STATES],
15000,
undefined,
existingIds
);
assertRecentMeeting(meeting, 120);
const meetingId = meeting.id;
createdMeetingIds.add(meetingId);
if (meeting.state !== 'recording') {
await startTone(meetingId, { ...TONE, seconds: 1 });
}
const stopButton = await $('button=Stop Recording');
await stopButton.waitForClickable({ timeout: 10000 });
await stopButton.click();
await stopButton.click();
await stopButton.click();
await waitForMeetingState(meetingId, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] multi-stop: meeting=${meetingId}`);
},
},
{
name: 'Start then immediate stop before injection completes',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle immediate stop ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] immediate-stop: meeting=${meeting.id}`);
},
},
{
name: 'Double start on same meeting should not crash',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle double start ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id);
const secondStart = await executeInApp({
type: 'startTranscriptionWithTone',
meetingId: meeting.id,
tone: TONE,
});
if (!secondStart.success) {
throw new Error(`Second start failed: ${secondStart.error ?? 'unknown error'}`);
}
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] double-start: meeting=${meeting.id}`);
},
},
{
name: 'Double stop on same meeting should leave recording stopped',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle double stop ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id);
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] double-stop: meeting=${meeting.id}`);
},
},
{
name: 'StopActiveRecordings when none are active',
async run() {
await ensureNoActiveRecordings();
const result = await executeInApp({ type: 'stopActiveRecordings' });
if (isErrorResult(result)) {
throw new Error(`stopActiveRecordings failed: ${result.error}`);
}
// Evidence
console.log(`[e2e-lifecycle] stop-active-none: stopped=${result.stopped ?? 0}`);
},
},
{
name: 'StopActiveRecordings stops an active recording',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle stop-active ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id);
const result = await executeInApp({ type: 'stopActiveRecordings' });
if (isErrorResult(result)) {
throw new Error(`stopActiveRecordings failed: ${result.error}`);
}
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] stop-active: stopped=${result.stopped ?? 0} meeting=${meeting.id}`);
},
},
{
name: 'Start new meeting while another recording is active should fail',
async run() {
await ensureNoActiveRecordings();
const first = await createMeeting(`Lifecycle overlap 1 ${TestData.generateTestId()}`);
createdMeetingIds.add(first.id);
await startTone(first.id);
const second = await createMeeting(`Lifecycle overlap 2 ${TestData.generateTestId()}`);
createdMeetingIds.add(second.id);
const secondStart = await executeInApp<{
success: boolean;
alreadyRecording?: boolean;
error?: string;
}>({
type: 'startTranscriptionWithTone',
meetingId: second.id,
tone: TONE,
});
if (secondStart.success && !secondStart.alreadyRecording) {
throw new Error('Expected second start to be rejected while recording is active');
}
await executeInApp({ type: 'stopMeeting', meetingId: first.id });
await waitForMeetingState(first.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] overlap-blocked: first=${first.id} second=${second.id}`);
},
},
{
name: 'Start-stop-start across meetings works back-to-back',
async run() {
await ensureNoActiveRecordings();
const first = await createMeeting(`Lifecycle chain 1 ${TestData.generateTestId()}`);
createdMeetingIds.add(first.id);
await startTone(first.id);
await executeInApp({ type: 'stopMeeting', meetingId: first.id });
await waitForMeetingState(first.id, ['stopped', 'completed']);
const second = await createMeeting(`Lifecycle chain 2 ${TestData.generateTestId()}`);
createdMeetingIds.add(second.id);
await startTone(second.id);
await executeInApp({ type: 'stopMeeting', meetingId: second.id });
await waitForMeetingState(second.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] chain-start: first=${first.id} second=${second.id}`);
},
},
{
name: 'Delete meeting while recording does not leave an active recording behind',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle delete-active ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id);
await deleteMeeting(meeting.id);
const recordings = await listMeetings(['recording']);
if (recordings.some((m) => m.id === meeting.id)) {
throw new Error('Deleted meeting still appears as recording');
}
// Evidence
console.log(`[e2e-lifecycle] delete-active: meeting=${meeting.id}`);
},
},
{
name: 'Long meeting resilience via repeated injections',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle long ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
let totalChunks = 0;
for (let i = 0; i < 5; i += 1) {
const startResult = await startTone(meeting.id, { ...TONE, seconds: 2 });
totalChunks += startResult.inject?.chunksSent ?? 0;
await browser.pause(200);
}
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
const stopped = await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(
`[e2e-lifecycle] long-meeting: meeting=${meeting.id} duration=${stopped.duration_seconds ?? 0} chunks=${totalChunks}`
);
},
},
{
name: 'Auto-stop after N minutes (test harness timer)',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle auto-stop ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
await browser.pause(3000); // Simulate N minutes in test
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] auto-stop (harness): meeting=${meeting.id} after=3s`);
},
},
{
name: 'Rapid create/delete cycles do not leak recording sessions',
async run() {
await ensureNoActiveRecordings();
for (let i = 0; i < 5; i += 1) {
const meeting = await createMeeting(`Lifecycle churn ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await deleteMeeting(meeting.id);
}
await ensureNoActiveRecordings();
// Evidence
console.log('[e2e-lifecycle] churn-delete: completed=5');
},
},
{
name: 'Navigate away and back during recording keeps state consistent',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle nav ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
await navigateTo('/meetings');
await browser.pause(500);
await navigateTo(`/recording/${meeting.id}`);
await waitForMeetingState(meeting.id, ['recording', 'stopped', 'completed']);
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] nav-during-recording: meeting=${meeting.id}`);
},
},
{
name: 'Add annotation during recording succeeds',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle annotation ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
const annotation = await executeInApp({
type: 'addAnnotation',
meetingId: meeting.id,
annotationType: 'note',
text: 'Lifecycle annotation during recording',
startTime: 0,
endTime: 1,
});
const annotationId =
annotation && typeof annotation === 'object' && 'id' in annotation
? String((annotation as { id?: unknown }).id)
: '';
if (!annotationId) {
throw new Error('Annotation creation failed while recording');
}
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] annotation-live: meeting=${meeting.id} annotation=${annotationId}`);
},
},
{
name: 'Generate summary after stop completes',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle summary ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
const summary = await executeInApp({ type: 'generateSummary', meetingId: meeting.id, force: true });
if (isErrorResult(summary)) {
throw new Error(`Summary generation failed: ${summary.error}`);
}
// Evidence
console.log(`[e2e-lifecycle] summary-after-stop: meeting=${meeting.id}`);
},
},
{
name: 'Concurrent stop and summary requests do not crash',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle stop-summary ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
const stopPromise = executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
const summaryPromise = executeInApp({ type: 'generateSummary', meetingId: meeting.id, force: true });
await Promise.all([stopPromise, summaryPromise]);
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] stop+summary: meeting=${meeting.id}`);
},
},
{
name: 'Repeated getMeeting polling during recording stays healthy',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle polling ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
for (let i = 0; i < 10; i += 1) {
const snapshot = await getMeeting(meeting.id);
if (!snapshot) {
throw new Error('getMeeting returned null while recording');
}
await browser.pause(200);
}
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] polling: meeting=${meeting.id}`);
},
},
{
name: 'Start recording after delete does not reuse deleted meeting',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle delete-restart ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await deleteMeeting(meeting.id);
const meetings = await listMeetings();
if (meetings.some((item) => item.id === meeting.id)) {
throw new Error('Deleted meeting still appears in list');
}
const replacement = await createMeeting(`Lifecycle delete-restart new ${TestData.generateTestId()}`);
createdMeetingIds.add(replacement.id);
await startTone(replacement.id, TONE);
await executeInApp({ type: 'stopMeeting', meetingId: replacement.id });
await waitForMeetingState(replacement.id, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] delete-restart: new=${replacement.id}`);
},
},
{
name: 'Rapid stop/start across new meetings stays stable',
async run() {
await ensureNoActiveRecordings();
const meetingIds: string[] = [];
for (let i = 0; i < 3; i += 1) {
const meeting = await createMeeting(`Lifecycle rapid chain ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
meetingIds.push(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 1 });
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
}
// Evidence
console.log(`[e2e-lifecycle] rapid-chain: meetings=${meetingIds.join(',')}`);
},
},
{
name: 'Stop recording while injecting tone continues gracefully',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle stop-during-inject ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
void startTone(meeting.id, { ...TONE, seconds: 2 }, { waitForRecording: false });
await waitForMeetingState(meeting.id, ['recording']);
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
await waitForMeetingState(meeting.id, ['stopped', 'completed']);
const recordings = await listMeetings(['recording']);
if (recordings.length > 0) {
throw new Error('Recording still active after stop during injection');
}
// Evidence
console.log(`[e2e-lifecycle] stop-during-inject: meeting=${meeting.id}`);
},
},
{
name: 'Start recording with blank title uses fallback safely',
async run() {
await ensureNoActiveRecordings();
await navigateTo('/recording/new');
const existingIds = await listMeetingIds([...ALL_MEETING_STATES]);
const startButton = await $('button=Start Recording');
await startButton.waitForClickable({ timeout: 5000 });
await startButton.click();
const meeting = await waitForLatestMeeting(
[...ALL_MEETING_STATES],
15000,
undefined,
existingIds
);
assertRecentMeeting(meeting, 120);
const meetingId = meeting.id;
const meetingSnapshot = await getMeeting(meetingId);
if (!meetingSnapshot) {
throw new Error('Meeting not found after blank-title start');
}
createdMeetingIds.add(meetingId);
if (meetingSnapshot.state !== 'recording') {
await startTone(meetingId, { ...TONE, seconds: 1 });
}
const stopButton = await $('button=Stop Recording');
await stopButton.waitForClickable({ timeout: 10000 });
await stopButton.click();
await waitForMeetingState(meetingId, ['stopped', 'completed']);
// Evidence
console.log(`[e2e-lifecycle] blank-title: meeting=${meetingId}`);
},
},
{
name: 'Recording state badge transitions are stable',
async run() {
await ensureNoActiveRecordings();
const meeting = await createMeeting(`Lifecycle badge ${TestData.generateTestId()}`);
createdMeetingIds.add(meeting.id);
await startTone(meeting.id, { ...TONE, seconds: 2 });
await waitForMeetingState(meeting.id, ['recording']);
await executeInApp({ type: 'stopMeeting', meetingId: meeting.id });
const stopped = await waitForMeetingState(meeting.id, ['stopped', 'completed']);
if (!stopped.state || stopped.state === 'recording') {
throw new Error('Meeting state did not transition out of recording');
}
// Evidence
console.log(`[e2e-lifecycle] badge-transition: meeting=${meeting.id} state=${stopped.state}`);
},
},
];
const failures: string[] = [];
for (const scenario of scenarios) {
try {
await scenario.run();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${scenario.name}: ${message}`);
// Evidence
console.log(`[e2e-lifecycle] FAILED ${scenario.name}: ${message}`);
}
}
if (failures.length > 0) {
throw new Error(`Lifecycle scenarios failed:\n${failures.join('\n')}`);
}
});
});