722 lines
29 KiB
TypeScript
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')}`);
|
|
}
|
|
});
|
|
});
|