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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user