Files
thu-maic--openmaic/e2e/tests/interactive-iframe-keepalive-619.spec.ts
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

222 lines
8.8 KiB
TypeScript

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 <id>`.
* 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 = `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;background:#eaf0ff">
<div style="text-align:center">
<div style="font-size:14px;color:#556">interactive widget</div>
<h1 id="count" style="font-size:72px;margin:8px 0;color:#2a3">0</h1>
<button id="inc" style="font-size:18px;padding:8px 18px">+1</button>
</div>
<script>
var n = 0, c = document.getElementById('count');
document.getElementById('inc').addEventListener('click', function () { c.textContent = String(++n); });
</script>
</body></html>`;
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<void>((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([]);
});
});