Skip to content
Open
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
134 changes: 134 additions & 0 deletions e2e/ui/export-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,47 @@ const hyperframesHtml = `<!doctype html>
</body>
</html>`;

const runtimeDeckHtml = `<!doctype html>
<html>
<head>
<title>Runtime Deck</title>
<script>
const style = document.createElement("style");
style.textContent = ".runtime { color: rgb(1, 2, 3); }";
document.head.appendChild(style);
</script>
</head>
<body>
<section class="slide runtime" data-slide-id="1"><h1>Runtime slide one</h1></section>
<section class="slide runtime" data-slide-id="2"><h1>Runtime slide two</h1></section>
</body>
</html>`;

const runtimePlainHtml = `<!doctype html>
<html><head><script>
const style = document.createElement("style");
style.textContent =
".runtime { color: rgb(1, 2, 3); }" +
".reveal { opacity: 0; }" +
".reveal.visible { opacity: 1; }" +
".entry { animation: fade-in 700ms linear forwards; }" +
"@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }";
document.head.appendChild(style);
addEventListener("DOMContentLoaded", () => {
const target = document.querySelector(".reveal");
if (target) new IntersectionObserver(() => target.classList.add("visible"), { threshold: 0 }).observe(target);
});
</script></head>
<body>
<p class="runtime reveal">Observer reveal</p>
<p class="runtime entry">Animated entry</p>
<p class="runtime">Runtime source-tab content</p>
</body></html>`;

const delayedHtml = `<!doctype html><html><head>
<script src="https://delayed.example/slow.js"></script>
</head><body><p class="runtime">Delayed clipboard content</p></body></html>`;

async function seedStore(page: Page, opts: SeedOptions) {
const now = 1_700_000_000_000;
const task = {
Expand Down Expand Up @@ -78,6 +119,31 @@ async function seedStore(page: Page, opts: SeedOptions) {
);
}

async function captureClipboardHtml(page: Page) {
await page.evaluate(() => {
Object.defineProperty(window, "ClipboardItem", {
configurable: true,
value: class ClipboardItem {
readonly items: Record<string, Blob | Promise<Blob>>;
constructor(items: Record<string, Blob | Promise<Blob>>) {
this.items = items;
}
},
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
write: async (items: Array<{ items: Record<string, Blob | Promise<Blob>> }>) => {
(window as typeof window & { __clipboardWriteStartedAt?: number }).__clipboardWriteStartedAt = performance.now();
const blob = await items[0]?.items["text/html"];
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml =
blob ? await blob.text() : "";
},
},
});
});
}

test.describe("Export menu", () => {
test("keeps Remotion hidden for regular HTML exports", async ({ page }) => {
await seedStore(page, { html: plainHtml });
Expand All @@ -96,6 +162,74 @@ test.describe("Export menu", () => {
await expect(menu.getByRole("button", { name: /Remotion project/ })).toHaveCount(0);
});

test("keeps computed styles when exporting from Source and Log tabs", async ({ page }) => {
await seedStore(page, { html: runtimePlainHtml });
await page.goto("/");
await captureClipboardHtml(page);

for (const tab of [/Source/, /Log/]) {
await page.getByRole("button", { name: tab }).click();
await page.evaluate(() => {
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml = "";
});
await page.getByRole("button", { name: /export/i }).click();
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();

await expect.poll(() =>
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
).toContain("Runtime source-tab content");
const copied = await page.evaluate(() =>
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? "",
);
expect(copied).toContain("color: rgb(1, 2, 3)");
expect(copied).toContain("Observer reveal");
expect(copied).toContain("Animated entry");
expect(copied).not.toContain("opacity: 0");
}
});

test("exports every computed-styled slide when slide 2 is selected", async ({ page }) => {
await seedStore(page, { html: runtimeDeckHtml });
await page.goto("/");
await captureClipboardHtml(page);

await page.getByRole("button", { name: "2", exact: true }).click();
await page.getByRole("button", { name: /export/i }).click();
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();

await expect.poll(() =>
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
).toContain("Runtime slide one");
const copied = await page.evaluate(() =>
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? "",
);
expect(copied).toContain("Runtime slide two");
expect(copied).toContain("color: rgb(1, 2, 3)");
});

test("starts the clipboard write before a slow full-document render settles", async ({ page }) => {
await page.route("https://delayed.example/slow.js", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 6000));
await route.fulfill({ contentType: "application/javascript", body: "document.body.dataset.ready = '1';" });
});
await seedStore(page, { html: delayedHtml });
await page.goto("/");
await captureClipboardHtml(page);

const clickStartedAt = await page.evaluate(() => performance.now());
await page.getByRole("button", { name: /export/i }).click();
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();

await expect.poll(() =>
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
{ timeout: 12_000 },
).toContain("Delayed clipboard content");
const writeStartedAt = await page.evaluate(() =>
(window as typeof window & { __clipboardWriteStartedAt?: number }).__clipboardWriteStartedAt ?? Infinity,
);
expect(writeStartedAt - clickStartedAt).toBeLessThan(1000);
});

test("exports a Hyperframes Remotion project zip from the UI", async ({ page }) => {
await seedStore(page, { html: hyperframesHtml });

Expand Down
239 changes: 239 additions & 0 deletions next/src/lib/export/__tests__/wechat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { describe, it, expect, afterEach } from "vitest";
import { renderToWechatHtml, toWechatHtmlFromDocument } from "../wechat";

function parseFragment(html: string): HTMLBodyElement {
return new DOMParser().parseFromString(`<body>${html}</body>`, "text/html")
.body as HTMLBodyElement;
}

describe("toWechatHtmlFromDocument", () => {
afterEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
});

it("inlines computed styles from the rendered preview DOM", () => {
document.head.innerHTML = `
<style>
.card {
background: rgb(12, 34, 56);
border-radius: 18px;
padding: 24px;
}
.title {
color: rgb(210, 55, 44);
font-size: 32px;
font-weight: 800;
line-height: 1.25;
}
</style>
`;
document.body.innerHTML = `
<article class="card">
<h1 class="title">Styled headline</h1>
</article>
`;

const body = parseFragment(toWechatHtmlFromDocument(document));
const section = body.querySelector("section");
const card = body.querySelector("article");
const title = body.querySelector("h1");

expect(section?.getAttribute("data-tool")).toBe("html-anything");
expect(card?.getAttribute("data-tool")).toBe("html-anything");
expect(card?.getAttribute("style")).toContain("background-color: rgb(12, 34, 56)");
expect(card?.getAttribute("style")).toContain("border-radius: 18px");
expect(card?.getAttribute("style")).toContain("padding: 24px");
expect(title?.getAttribute("style")).toContain("color: rgb(210, 55, 44)");
expect(title?.getAttribute("style")).toContain("font-size: 32px");
expect(title?.getAttribute("style")).toContain("font-weight: 800");
});

it("drops fragile page-layout styles so WeChat paste stays in article flow", () => {
document.head.innerHTML = `
<style>
.layout {
position: absolute;
display: grid;
grid-template-columns: 320px 1fr;
width: 1280px;
height: 720px;
gap: 48px;
background: rgb(250, 248, 240);
padding: 40px;
}
.panel {
display: flex;
min-height: 360px;
color: rgb(25, 28, 32);
border: 2px solid rgb(80, 90, 100);
}
</style>
`;
document.body.innerHTML = `
<section class="layout">
<p class="panel">First paragraph</p>
<p class="panel">Second paragraph</p>
</section>
`;

const html = toWechatHtmlFromDocument(document);
const body = parseFragment(html);
const layoutStyle = body.querySelector(".layout")?.getAttribute("style") ?? "";
const panelStyle = body.querySelector(".panel")?.getAttribute("style") ?? "";

expect(layoutStyle).toContain("background-color: rgb(250, 248, 240)");
expect(layoutStyle).toContain("padding: 40px");
expect(panelStyle).toContain("color: rgb(25, 28, 32)");
expect(panelStyle).toContain("border-top: 2px solid rgb(80, 90, 100)");
expect(`${layoutStyle}; ${panelStyle}`).not.toMatch(
/(?:^|;\s*)(position|display|grid-template-columns|width|height|min-height|gap|flex-direction|flex-wrap|flex):/,
);
});

it("materializes ::before and ::after content into real DOM nodes", () => {
document.body.innerHTML = `
<ul>
<li class="check">Item one</li>
</ul>
<p class="tier">Pro plan</p>
`;

const original = window.getComputedStyle.bind(window);
const stub = ((el: Element, pseudo?: string | null) => {
const base = original(el);
if (!pseudo) return base;
const overrides: Record<string, string> = {};
if (pseudo === "::before" && (el as HTMLElement).matches?.(".check")) {
overrides.content = '"✓"';
overrides.color = "rgb(255, 0, 0)";
} else if (pseudo === "::before" && (el as HTMLElement).matches?.(".tier")) {
overrides.content = '"Recommended"';
overrides.color = "rgb(255, 255, 255)";
} else if (pseudo === "::after" && (el as HTMLElement).matches?.(".tier")) {
overrides.content = '"★"';
}
return new Proxy(base, {
get(target, prop) {
if (prop === "getPropertyValue") {
return (name: string) => overrides[name] ?? target.getPropertyValue(name);
}
const value = (target as unknown as Record<string | symbol, unknown>)[prop];
return typeof value === "function" ? (value as () => unknown).bind(target) : value;
},
});
}) as typeof window.getComputedStyle;
window.getComputedStyle = stub;

try {
const body = parseFragment(toWechatHtmlFromDocument(document));
const check = body.querySelector("li.check");
const tier = body.querySelector("p.tier");

const checkBefore = check?.firstElementChild;
expect(checkBefore?.getAttribute("data-pseudo")).toBe("::before");
expect(checkBefore?.textContent).toBe("✓");
expect(checkBefore?.getAttribute("style") ?? "").toContain("color: rgb(255, 0, 0)");

const tierBefore = tier?.firstElementChild;
expect(tierBefore?.getAttribute("data-pseudo")).toBe("::before");
expect(tierBefore?.textContent).toBe("Recommended");

const tierAfter = tier?.lastElementChild;
expect(tierAfter?.getAttribute("data-pseudo")).toBe("::after");
expect(tierAfter?.textContent).toBe("★");
} finally {
window.getComputedStyle = original as typeof window.getComputedStyle;
}
});

it("keeps descendant styles aligned when an ancestor has generated content", () => {
document.body.innerHTML = `
<div class="card"><span class="label">Hello</span></div>
`;

const original = window.getComputedStyle.bind(window);
const stub = ((el: Element, pseudo?: string | null) => {
const base = original(el);
const overrides: Record<string, string> = {};
if (pseudo === "::before" && (el as HTMLElement).matches?.(".card")) {
overrides.content = '"Badge"';
} else if (!pseudo && (el as HTMLElement).matches?.(".label")) {
overrides.color = "rgb(255, 0, 0)";
}
return new Proxy(base, {
get(target, prop) {
if (prop === "getPropertyValue") {
return (name: string) => overrides[name] ?? target.getPropertyValue(name);
}
const value = (target as unknown as Record<string | symbol, unknown>)[prop];
return typeof value === "function" ? (value as () => unknown).bind(target) : value;
},
});
}) as typeof window.getComputedStyle;
window.getComputedStyle = stub;

try {
const body = parseFragment(toWechatHtmlFromDocument(document));
const card = body.querySelector(".card");
const pseudo = card?.querySelector("[data-pseudo='::before']");
const label = card?.querySelector(".label");

expect(pseudo?.textContent).toBe("Badge");
expect(pseudo?.getAttribute("style") ?? "").not.toContain("color: rgb(255, 0, 0)");
expect(label?.getAttribute("style") ?? "").toContain("color: rgb(255, 0, 0)");
} finally {
window.getComputedStyle = original as typeof window.getComputedStyle;
}
});

it("omits computed-hidden speaker notes", () => {
document.head.innerHTML = `<style>.notes { display: none !important; }</style>`;
document.body.innerHTML = `
<section class="slide"><h1>Visible slide</h1><aside class="notes">SECRET NOTES</aside></section>
`;

const exported = toWechatHtmlFromDocument(document);
expect(exported).toContain("Visible slide");
expect(exported).not.toContain("SECRET NOTES");
expect(exported).not.toContain("class=\"notes\"");
});

it("clamps oversized spacing and drops negative margins", () => {
document.head.innerHTML = `
<style>
.loose {
margin-top: -24px;
margin-bottom: 180px;
padding: 96px;
color: rgb(40, 40, 40);
}
</style>
`;
document.body.innerHTML = `<p class="loose">Too much spacing</p>`;

const body = parseFragment(toWechatHtmlFromDocument(document));
const style = body.querySelector("p")?.getAttribute("style") ?? "";

expect(style).toContain("margin-bottom: 48px");
expect(style).toContain("padding: 48px");
expect(style).toContain("color: rgb(40, 40, 40)");
expect(style).not.toContain("-24px");
expect(style).not.toContain("180px");
expect(style).not.toContain("96px");
});

it("keeps every runtime-styled deck slide in the full rendered export", async () => {
const fullDeck = `<!doctype html><html><head>
<style>.runtime { color: rgb(1, 2, 3); }</style>
</head><body>
<section class="slide runtime" data-slide-id="1"><h1>Slide one</h1></section>
<section class="slide runtime" data-slide-id="2"><h1>Slide two</h1></section>
</body></html>`;

const exported = await renderToWechatHtml(fullDeck);
expect(exported).toContain("Slide one");
expect(exported).toContain("Slide two");
expect(exported).toContain("color: rgb(1, 2, 3)");
});
});
Loading
Loading