Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
/**
* Integration tests using WordPress Theme Unit Test data
*
* Tests the full WordPress migration pipeline against the official
* WordPress Theme Unit Test dataset. The test data is downloaded from
* GitHub on first run and cached locally.
*
* @see https://github.com/WordPress/theme-test-data
*/
import { createReadStream, existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { gutenbergToPortableText } from "@emdash-cms/gutenberg-to-portable-text";
import { describe, it, expect, beforeAll } from "vitest";
import { parseWxr } from "../../../src/cli/wxr/parser.js";
// Test regex patterns
const PARAGRAPH_WITH_TEXT_REGEX = /<p[^>]*>[^<]+<\/p>/;
const TEST_DATA_PATH = join(
process.cwd(),
"../../examples/wp-theme-unit-test/themeunittestdata.wordpress.xml",
);
const TEST_DATA_URL =
"https://raw.githubusercontent.com/WordPress/theme-test-data/master/themeunittestdata.wordpress.xml";
/**
* Download the WordPress theme unit test data if it doesn't exist locally.
*/
async function ensureTestData(): Promise<void> {
if (existsSync(TEST_DATA_PATH)) return;
console.log(`Downloading WordPress theme unit test data from ${TEST_DATA_URL}...`);
const response = await fetch(TEST_DATA_URL);
if (!response.ok) {
throw new Error(`Failed to download test data: ${response.status} ${response.statusText}`);
}
const data = await response.text();
await mkdir(dirname(TEST_DATA_PATH), { recursive: true });
await writeFile(TEST_DATA_PATH, data, "utf-8");
console.log(`Downloaded to ${TEST_DATA_PATH}`);
}
describe("WordPress Theme Unit Test Migration", () => {
let wxrData: Awaited<ReturnType<typeof parseWxr>>;
beforeAll(async () => {
await ensureTestData();
const stream = createReadStream(TEST_DATA_PATH, { encoding: "utf-8" });
wxrData = await parseWxr(stream);
});
describe("WXR Parsing", () => {
it("parses site metadata", () => {
expect(wxrData.site.title).toBe("Theme Unit Test Data");
expect(wxrData.site.link).toBe("https://wpthemetestdata.wordpress.com");
expect(wxrData.site.language).toBe("en");
});
it("parses all posts", () => {
// Theme Unit Test has many posts covering different scenarios
expect(wxrData.posts.length).toBeGreaterThan(50);
});
it("parses all pages", () => {
const pages = wxrData.posts.filter((p) => p.postType === "page");
expect(pages.length).toBeGreaterThan(10);
});
it("parses categories with hierarchy", () => {
expect(wxrData.categories.length).toBeGreaterThan(20);
// Check for parent-child relationships
const parentCategory = wxrData.categories.find((c) => c.nicename === "parent-category");
expect(parentCategory).toBeDefined();
const childCategory = wxrData.categories.find((c) => c.nicename === "child-category-01");
expect(childCategory).toBeDefined();
expect(childCategory?.parent).toBe("parent-category");
});
it("parses tags", () => {
expect(wxrData.tags.length).toBeGreaterThan(50);
// Check for specific tags
const wpTag = wxrData.tags.find((t) => t.slug === "wordpress");
expect(wpTag).toBeDefined();
expect(wpTag?.name).toBe("WordPress");
});
it("parses authors", () => {
expect(wxrData.authors.length).toBeGreaterThanOrEqual(1);
const author = wxrData.authors.find((a) => a.login === "themereviewteam");
expect(author).toBeDefined();
expect(author?.displayName).toBe("Theme Reviewer");
});
it("parses attachments", () => {
expect(wxrData.attachments.length).toBeGreaterThan(0);
});
it("parses post categories and tags", () => {
// Find a post with both categories and tags
const postsWithTaxonomies = wxrData.posts.filter(
(p) => p.categories.length > 0 || p.tags.length > 0,
);
expect(postsWithTaxonomies.length).toBeGreaterThan(0);
});
});
describe("Gutenberg Block Conversion", () => {
it("converts paragraph blocks", () => {
const post = wxrData.posts.find((p) => p.content?.includes("wp:paragraph"));
expect(post).toBeDefined();
const result = gutenbergToPortableText(post!.content || "");
expect(result.length).toBeGreaterThan(0);
const block = result.find((b) => b._type === "block");
expect(block).toBeDefined();
});
it("converts heading blocks with different levels", () => {
const post = wxrData.posts.find((p) => p.title === "WP 6.1 Font size scale");
expect(post).toBeDefined();
const result = gutenbergToPortableText(post!.content || "");
// Should have h2 headings
const headings = result.filter(
(b) => b._type === "block" && (b as any).style?.startsWith("h"),
);
expect(headings.length).toBeGreaterThan(0);
});
it("converts list blocks", () => {
// Find a post with list content
const post = wxrData.posts.find((p) => p.content?.includes("wp:list"));
if (post) {
const result = gutenbergToPortableText(post.content || "");
const listItems = result.filter((b) => b._type === "block" && (b as any).listItem);
expect(listItems.length).toBeGreaterThan(0);
}
});
it("converts image blocks", () => {
const post = wxrData.posts.find((p) => p.content?.includes("wp:image"));
if (post) {
const result = gutenbergToPortableText(post.content || "");
const images = result.filter((b) => b._type === "image");
expect(images.length).toBeGreaterThan(0);
}
});
it("converts quote blocks", () => {
const post = wxrData.posts.find((p) => p.content?.includes("wp:quote"));
if (post) {
const result = gutenbergToPortableText(post.content || "");
const quotes = result.filter(
(b) => b._type === "block" && (b as any).style === "blockquote",
);
expect(quotes.length).toBeGreaterThan(0);
}
});
it("converts code blocks", () => {
const post = wxrData.posts.find((p) => p.content?.includes("wp:code"));
if (post) {
const result = gutenbergToPortableText(post.content || "");
const codeBlocks = result.filter((b) => b._type === "code");
expect(codeBlocks.length).toBeGreaterThan(0);
}
});
it("converts group blocks by flattening", () => {
const post = wxrData.posts.find((p) => p.content?.includes("wp:group"));
expect(post).toBeDefined();
const result = gutenbergToPortableText(post!.content || "");
// Groups should be flattened - no group type in output
const groups = result.filter((b) => b._type === "group");
expect(groups.length).toBe(0);
// But their content should still be present
expect(result.length).toBeGreaterThan(0);
});
it("handles classic editor content", () => {
// Find a post in the "Classic" category
const classicPost = wxrData.posts.find((p) => p.categories.includes("classic"));
if (classicPost && classicPost.content) {
// Classic content doesn't have wp: comments
const hasGutenbergBlocks = classicPost.content.includes("<!-- wp:");
if (!hasGutenbergBlocks && classicPost.content.trim()) {
const result = gutenbergToPortableText(classicPost.content);
expect(result.length).toBeGreaterThan(0);
}
}
});
it("preserves inline formatting", () => {
const post = wxrData.posts.find(
(p) => p.content?.includes("<strong>") || p.content?.includes("<em>"),
);
if (post) {
const result = gutenbergToPortableText(post.content || "");
const blocksWithMarks = result.filter(
(b) => b._type === "block" && (b as any).children?.some((c: any) => c.marks?.length > 0),
);
// Should have some formatted text
expect(blocksWithMarks.length).toBeGreaterThanOrEqual(0);
}
});
it("handles empty content gracefully", () => {
const result = gutenbergToPortableText("");
expect(result).toEqual([]);
});
it("handles malformed blocks gracefully", () => {
// Test with incomplete block markers
const malformed = "<!-- wp:paragraph --><p>Test<!-- /wp:paragraph";
const result = gutenbergToPortableText(malformed);
// Should not throw, may produce partial output or fallback
expect(Array.isArray(result)).toBe(true);
});
});
describe("Edge Cases", () => {
it("handles posts with special characters in title", () => {
// Find posts with special characters
const specialPosts = wxrData.posts.filter(
(p) => p.title?.includes("&") || p.title?.includes("<") || p.title?.includes('"'),
);
// Should parse without errors
expect(specialPosts).toBeDefined();
});
it("handles posts with very long content", () => {
// Find the longest post
const longestPost = wxrData.posts.reduce((longest, current) => {
const currentLength = current.content?.length || 0;
const longestLength = longest?.content?.length || 0;
return currentLength > longestLength ? current : longest;
}, wxrData.posts[0]);
if (longestPost?.content) {
const result = gutenbergToPortableText(longestPost.content);
expect(result.length).toBeGreaterThan(0);
}
});
it("handles deeply nested blocks", () => {
// Find posts with nested structures (columns, groups)
const nestedPost = wxrData.posts.find(
(p) => p.content?.includes("wp:columns") || p.content?.includes("wp:group"),
);
if (nestedPost) {
const result = gutenbergToPortableText(nestedPost.content || "");
expect(Array.isArray(result)).toBe(true);
}
});
it("handles posts with embeds", () => {
const embedPost = wxrData.posts.find((p) => p.content?.includes("wp:embed"));
if (embedPost) {
const result = gutenbergToPortableText(embedPost.content || "");
const embeds = result.filter((b) => b._type === "embed");
expect(embeds.length).toBeGreaterThanOrEqual(0);
}
});
});
describe("Content Integrity", () => {
it("preserves all text content through conversion", () => {
// Take a sample of posts and verify text isn't lost
const samplePosts = wxrData.posts.slice(0, 10);
for (const post of samplePosts) {
if (!post.content) continue;
const result = gutenbergToPortableText(post.content);
// Extract all text from result
const extractedText = result
.map((block) => {
if (block._type === "block" && (block as any).children) {
return (block as any).children.map((c: any) => c.text || "").join("");
}
if (block._type === "code") {
return (block as any).code || "";
}
return "";
})
.join(" ")
.trim();
// If there was content, we should have extracted some text
// (unless it was all images/embeds)
if (post.content.includes("<p>") || post.content.includes("wp:paragraph")) {
// Only check if there was actual text content
const hasTextContent = PARAGRAPH_WITH_TEXT_REGEX.test(post.content);
if (hasTextContent) {
expect(extractedText.length).toBeGreaterThan(0);
}
}
}
});
});
describe("Statistics", () => {
it("reports conversion statistics", () => {
let totalPosts = 0;
let successfulConversions = 0;
let failedConversions = 0;
let totalBlocks = 0;
const blockTypes = new Map<string, number>();
for (const post of wxrData.posts) {
totalPosts++;
try {
const result = gutenbergToPortableText(post.content || "");
successfulConversions++;
totalBlocks += result.length;
for (const block of result) {
const type = block._type;
blockTypes.set(type, (blockTypes.get(type) || 0) + 1);
}
} catch {
failedConversions++;
}
}
// Log statistics (visible in test output with --reporter=verbose)
console.log("\n=== WordPress Migration Statistics ===");
console.log(`Total posts: ${totalPosts}`);
console.log(`Successful: ${successfulConversions}`);
console.log(`Failed: ${failedConversions}`);
console.log(`Total blocks generated: ${totalBlocks}`);
console.log("\nBlock types:");
for (const [type, count] of blockTypes.entries()) {
console.log(` ${type}: ${count}`);
}
console.log("=====================================\n");
// All conversions should succeed
expect(failedConversions).toBe(0);
expect(successfulConversions).toBe(totalPosts);
});
});
});