import { test, expect } from '../fixtures/base'; import { ClassroomPage } from '../pages/classroom.page'; import { createSettingsStorage } from '../fixtures/test-data/settings'; import { defaultTheme } from '../fixtures/test-data/scene-content'; /** * E2E for #619: interactive scene iframes must NOT reload on remount. * * The keep-alive iframe is identified by its title `Interactive Scene `. * A MutationObserver on the top document records every add/remove of *that* * iframe element. The proof of keep-alive: after each remount trigger (Pro mode * toggle, scene switch and back) the element is neither removed nor recreated * (zero recorded mutations), and the click-counter state it holds is preserved. * * Note: the slide nav rail also renders a separate, stateless `Interactive * Preview` thumbnail iframe that does reload on re-render — a different surface, * out of scope here. Scoping the observer to the playback iframe's title keeps * the assertion clean of that noise. */ const TEST_STAGE_ID = 'e2e-iframe-keepalive'; const INTERACTIVE_SCENE_ID = 'scene-interactive'; const IFRAME_TITLE = `Interactive Scene ${INTERACTIVE_SCENE_ID}`; const SHOTS = 'e2e/__screenshots-619'; const SETTINGS_STORAGE = createSettingsStorage({ sidebarCollapsed: false }); const INTERACTIVE_HTML = `
interactive widget

0

`; async function seedDatabase(page: import('@playwright/test').Page) { await page.addInitScript((settings) => { localStorage.setItem('settings-storage', settings); }, SETTINGS_STORAGE); await page.goto('/', { waitUntil: 'networkidle' }); await page.evaluate( ({ stageId, interactiveId, html, theme }) => { return new Promise((resolve, reject) => { const request = indexedDB.open('MAIC-Database'); request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const tx = db.transaction(['stages', 'scenes', 'stageOutlines'], 'readwrite'); const now = Date.now(); tx.objectStore('stages').put({ id: stageId, name: 'Keep-alive test', description: '', language: 'en-US', style: 'professional', currentSceneId: interactiveId, createdAt: now, updatedAt: now, }); tx.objectStore('scenes').put({ id: interactiveId, stageId, type: 'interactive', title: 'Interactive', order: 0, content: { type: 'interactive', url: '', html }, createdAt: now, updatedAt: now, }); tx.objectStore('scenes').put({ id: 'scene-slide', stageId, type: 'slide', title: 'A slide', order: 1, content: { type: 'slide', canvas: { id: 'slide-1', viewportSize: 1000, viewportRatio: 0.5625, theme, elements: [ { type: 'text', id: 'el-1', content: 'A slide', left: 50, top: 50, width: 900, height: 100, }, ], }, }, createdAt: now, updatedAt: now, }); tx.objectStore('stageOutlines').put({ stageId, outlines: [], createdAt: now, updatedAt: now, }); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); }; request.onerror = () => reject(request.error); }); }, { stageId: TEST_STAGE_ID, interactiveId: INTERACTIVE_SCENE_ID, html: INTERACTIVE_HTML, theme: defaultTheme, }, ); } const keepAliveMutations = (page: import('@playwright/test').Page) => page.evaluate(() => (window as unknown as { __kaMut: string[] }).__kaMut.slice()); const resetMutations = (page: import('@playwright/test').Page) => page.evaluate(() => { (window as unknown as { __kaMut: string[] }).__kaMut.length = 0; }); test.describe('#619 interactive iframe keep-alive', () => { test('iframe survives Pro-mode toggle and scene switch without reloading', async ({ page }) => { // Record add/remove of the keep-alive iframe (by title) in the top document. await page.addInitScript((title) => { if (window.top !== window.self) return; (window as unknown as { __kaMut: string[] }).__kaMut = []; const log = (window as unknown as { __kaMut: string[] }).__kaMut; const isTarget = (n: Node) => n instanceof HTMLIFrameElement && n.getAttribute('title') === title; const mo = new MutationObserver((muts) => { for (const m of muts) { m.addedNodes.forEach((n) => isTarget(n) && log.push('ADD')); m.removedNodes.forEach((n) => isTarget(n) && log.push('REM')); } }); document.addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true }), ); }, IFRAME_TITLE); await seedDatabase(page); const classroom = new ClassroomPage(page); await classroom.goto(TEST_STAGE_ID); await classroom.waitForLoaded(); const iframeEl = page.locator(`iframe[title="${IFRAME_TITLE}"]`); const frame = page.frameLocator(`iframe[title="${IFRAME_TITLE}"]`); const count = frame.locator('#count'); // Initial render. Build in-iframe state that only survives a true keep-alive. await expect(iframeEl).toBeVisible({ timeout: 15_000 }); await expect(count).toHaveText('0'); await frame.locator('#inc').click(); await frame.locator('#inc').click(); await frame.locator('#inc').click(); await expect(count).toHaveText('3'); await page.screenshot({ path: `${SHOTS}/01-interactive-counter-3.png`, fullPage: true }); // Ignore any initial mount churn (incl. dev StrictMode); from here on the // keep-alive iframe must not be added or removed again. await resetMutations(page); // `.first()` because the ~280ms mode cross-fade briefly mounts both chrome // layers (each with its own Pro switch). // --- Trigger A: Pro mode toggle (edit chrome remounts the placeholder) --- await page.getByRole('switch').first().click(); // Since #777 the interactive iframe stays VISIBLE in edit mode — the editor // agent ("Edit with AI") fixes interactive HTML, so the teacher must see the // live page while editing. The keep-alive proof is that it is neither // unmounted nor reloaded: the in-iframe counter state survives the toggle. await expect(iframeEl).toBeVisible(); await expect(count).toHaveText('3'); // state preserved across the mode toggle await page.screenshot({ path: `${SHOTS}/02-pro-mode.png`, fullPage: true }); await page.getByRole('switch').first().click(); await expect(iframeEl).toBeVisible(); // back to playback await expect(count).toHaveText('3'); // state preserved → no reload // Let the ~280ms mode cross-fade fully settle: the outgoing chrome's // placeholder cleanup fires late, and a non-owner-checked release would hide // the iframe here (the blank-on-return regression). It must stay visible. await page.waitForTimeout(600); await expect(iframeEl).toBeVisible(); await expect(count).toHaveText('3'); await page.screenshot({ path: `${SHOTS}/03-back-from-pro.png`, fullPage: true }); // --- Trigger B: scene switch away and back (placeholder unmount/remount) --- await classroom.clickScene(1); // slide await expect(page.getByRole('heading', { name: 'A slide' })).toBeVisible(); await expect(iframeEl).toBeHidden(); await classroom.clickScene(0); // back to interactive await expect(iframeEl).toBeVisible(); await expect(count).toHaveText('3'); // state preserved → no reload await page.screenshot({ path: `${SHOTS}/04-back-from-scene-switch.png`, fullPage: true }); // The keep-alive iframe element was never removed or recreated across either // remount trigger — the document (and its in-iframe state) was preserved. expect(await keepAliveMutations(page)).toEqual([]); }); });